Pull to refresh

Comments 17

Спасибо за статью!

Но в ней есть одна небольшая неточность понимания или формулировки, усложняющая понимание этого механизма.

Боксинг — это когда struct нужно передать туда, где ждут ссылку на объект. Рантайм выделяет объект на куче и копирует туда значение структуры. Каждый боксинг — это аллокация, и ее видно в колонке Allocated у BenchmarkDotNet.

Вовсе не так. Нет никакой проблемы передать struct по ссылке. Проблема в другом - невозможно выделить на стеке struct, размер которого не знает компилятор. Это важное ограничение стека.

var list = new List<int> { 1, 2, 3 };
 
// Тип переменной - List<int>. foreach берет метод №1.
// Struct-энумератор живет на стеке. Аллокаций: 0.
foreach (var n in list) { }

public struct Enumerator : IEnumerator<T>, IEnumerator
        {
            private readonly List<T> _list;
            private readonly int _version;

            private int _index;
            private T? _current;
  ...
}

Здесь конкретный struct размер которого понятен компилятору. Размер T будет определен для каждого конкретного типа, остальные размеры фиксированные. Можно выделять в стеке без проблем.

// Тип переменной - IEnumerable<int>. foreach берет метод №2.
// Тот же struct, но боксированный на кучу.
// Аллокаций: 1 объект на каждый foreach.
IEnumerable<int> seq = list;
foreach (var n in seq) { }

public interface IEnumerator<out T> : IDisposable, IEnumerator
    where T : allows ref struct
{
    new T Current
    {
        get;
    }
}

А вот здесь уже, все что знает компилятор так это размер T. В остальное в зависимости от реализации может гулять. Нельзя формировать стек размер которого будут гулять.

Именно по этому, в данном случае, компилятор проводит боксинг т.е. делает из неизвестного размера struct известный - размер ссылки и решает эту проблему.

И это проклятье любого интерфейса за которым спрятан struct. Именно поэтому struct за дженериком ограниченный интерфейсом и не вызывает аллокаций а за интерфейсом вызывает.

боксинг не про размер! Даже стек там не светился!! sharpobject obj = 42; // размер известен — боксинг! void P(T num) { } // размер неизвестен боксинга нет! интерфейсу и object объект в куче, иначе виртуальные вызовы не работают. Вот struct и заворачивают

боксинг не про размер! Даже стек там не светился!! sharpobject obj = 42; // размер известен — боксинг!

Окей. Какого размера sharpobject? Наверное это int размером 4? Точно? А если так?

object obj = 42;
obj = 42L; // ???

Или так?

object obj = 42;
obj = 42L; // ???
obj = "42"; // ???

А если так?

public static void Fu(object obj) {
}

Компилятор понятия не имеет что у вас будет лежать в object и поэтому вынужден использовать наиболее обобщенный тип - ссылку размером 8 байт размещая данные в куче.

 void P(T num) { } // размер неизвестен боксинга нет!

Так как T это дженерик а в C# дженерики специализируются то компилятору и не нужно знать T, вместо него будет конкретный тип с конкретным размером.

интерфейсу и object объект в куче, иначе виртуальные вызовы не работают. Вот struct и заворачивают

Вы не понимаете что просто не можете разместить struct находящийся за интерфейсом в стеке, потому что количество реализаций и следовательно размеров памяти которую потребуется выделить неопределенно?

Давайте на пальцах в том числе про виртуальные вызовы. Пример первый, с боксингом:

public interface IA {
    int operation(int a, int b);
}
public record struct B(int X) : IA {
    public int operation(int a, int b) {
        return a + b + X;
    }
}
public struct C : IA {
    public int operation(int a, int b) {
        return a * b;
    }
}
public static int Fu(IA some, int a, int b) {
    return some.operation(a, b);
}

В методе Fu будет боксинг. Потому что нельзя создать такую функцию Fu которая будет принимать первым параметром структуру любого размера. У нас в примере структура B имеет размер 4 байта а структура 0 байт. Поэтому компилятор вынужден приводить неизвестный размер к известному - ссылку в 8 байт. Затем действительно происходит виртуальный вызов.

Второй пример, без боксинга:

public static int Fu2<TA>(TA some, int a, int b) where TA : IA {
    return some.operation(a, b);
}

Все тоже самое но боксинга нет. Потому что компилятор в данном случае сгенерирует два варианта функции Fu2, который будут выглядеть примерно так:

public static int Fu2B(B some, int a, int b) {
    return some.operation(a, b);
}
public static int Fu2C(C some, int a, int b) {
    return some.operation(a, b);
}

Никакой боксинг уже не нужен так как для каждого типа своя функция с известным размером. Никакие виртуальные вызовы тоже не нужны, потому как структура будет вызывать просто свой метод.

Вы можете спросить: а как же наследование без виртуальных функций? На что я отвечу что нет никакого наследования для struct. Если даже в интерфейсе будет дофлотная реализация метода а в структуре не будет ее реализации то вызов такого метода структуры будет просто прямым вызовом метода интерфейса.

Весь боксинг - это исключительно игра с размерами.

Вы можете спросить: а как же наследование без виртуальных функций? На что я отвечу что нет никакого наследования для struct.

Правильно спросить не “а как же наследование?”, а “а как же полиморфизм?”. Интерфейс - он ведь именно про полиморфизм, тоесть, про косвенные вызовы методов чере таблицу виртуальных методов. А наследовать там нечего: интерфейс - не класс, в нем (по крайней мере, в его изначальной идее) нет ничего своего, кроме контракта, то есть, набора этих самых виртуальных методов, включая get/set для свойств.

Так что, хотя от значимых типов (т.е. struct) наследовать нельзя, интерфейс обеспечивает для них полиморфизм: в коде можно использовать один и тот же метод для работы с разными значениями этих самых значимых типов, даже не зная, с каким именно типом код работает в данный момент.

И вот чтобы обеспечить этот самый полиморфизм, приходится для этого (в общем случае, деабстракции в JIT как раз позволяет это в конкретных случаях, где без полиморфизма можно обойтись, избегать) размещать значение значимого типа в куче - ибо ссылка на таблицу виртуальных методов ЕМНИП живет именно там, в заголовке объекта в куче.

Давайте по пунктам.

  1. «Компилятор понятия не имеет, что у вас будет лежать в object». Имеет — в каждой точке боксинга тип известен статически. Ваш же пример в IL:

    box  [System.Runtime]System.Int32   // obj = 42
    box  [System.Runtime]System.Int64   // obj = 42L

    Каждая инструкция box эмитится для конкретного, известного компилятору типа (ECMA-335, III.4.1). «Неизвестного размера» здесь нет нигде. А то, что переменная object хранит 8-байтовую ссылку — это и есть определение из статьи: место хранения типа object/интерфейс держит ссылку на объект.

  2. Если бы боксинг был «исключительно игрой с размерами», куча была бы не нужна. Managed pointer на struct в стеке — тоже фиксированные 8 байт.

    Именно так работают ref/in и constrained.callvirt (ECMA-335, III.2.1): вызов интерфейсного метода на struct идёт по указателю, без единой аллокации.

    Почему же ваш Fu(IA some, ...) так не может? Две причины, и обе не про размер.

    Диспетчеризация. В Fu приходят и B, и C — обе ссылки по 8 байт. Как рантайм выбирает, чей operation звать? По method table в заголовке боксированного объекта. У «голого» struct заголовка нет — размер ссылки тут ничего не решает.

    Время жизни. IA some можно сохранить в поле, в коллекцию, захватить в замыкание — ссылка обязана переживать фрейм вызова. Указатель на стек так не может. Вот настоящее «ограничение стека»: lifetime, а не неизвестный размер.

    Сделайте обе ваши структуры ровно по 4 байта. Размеры известны и одинаковы — боксинг в Fu(IA) никуда не денется.

  3. «Компилятор сгенерирует два варианта Fu2». Не компилятор, а JIT в рантайме — компилятор C# эмитит один generic-метод в IL, размер TA на этапе компиляции ему неизвестен. Это прямо противоречит вашей же схеме «компилятор должен знать размер»: размер неизвестен — боксинга нет.

    И про «нет виртуальных вызовов для struct»: вызов some.operation через интерфейс на боксированной структуре — это самый настоящий виртуальный вызов через method table. Уберите его — и ваш Fu перестанет работать.

Определение боксинга — не моя интерпретация: «converting a value type to the type object or to any interface type... wraps the value inside a System.Object instance and stores it on the managed heap», ECMA-335 §III.4.1.

Если я вас ни с кем не путаю, то уже во второй ветке я читаю эту вашу интерпретацию боксинга.

Так вот, она неверная.

Ниже вам верно ответили: боксинг - это превращение инстанса типа без заголовка (в .нете - всегда struct) в инстанс типа с заголовком (в .нете всегда любой ссылочный тип)

Интерфейс в .нете - всегда ссылочный тип с заголовком (вкл. vtable)

Не обязательно верить мне, достаточно прочитать о боксинге в документации .нета/clr.

Если же вы так уверены в своей интерпретации - можно получить подтверждающие ссылки на какие-либо авторитетные источники?

А ещё было бы хорошо добавить в сравнение AOT. Для некоторых платформ это единственный вариант. Возможно, я что-то делаю не так, но вот с AOT всё не так радужно и красиво как c прогретым JIT. Мало того - первый или единственный запуск будет скорее всего именно таким.

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8655/25H2/2025Update/HudsonValley2)
12th Gen Intel Core i7-12700 2.10GHz, 1 CPU, 20 logical and 12 physical cores
.NET SDK 10.0.301
  [Host]         : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
  .NET 10.0      : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
  NativeAOT 10.0 : .NET 10.0.9, X64 NativeAOT x86-64-v3
| Method                           | Runtime        | Mean      | Ratio | Allocated | Alloc Ratio |
|--------------------------------- |--------------- |----------:|------:|----------:|------------:|
| Dictionary_Foreach               | .NET 10.0      |  7.576 ns |  1.00 |         - |          NA |
| SortedList_Foreach               | .NET 10.0      |  9.573 ns |  1.26 |         - |          NA |
| Dictionary_AsIEnumerable_Foreach | .NET 10.0      |  9.167 ns |  1.21 |         - |          NA |
| Dictionary_Foreach               | NativeAOT 10.0 | 28.415 ns |  3.75 |         - |          NA |
| SortedList_Foreach               | NativeAOT 10.0 | 45.332 ns |  5.98 |      48 B |          NA |
| Dictionary_AsIEnumerable_Foreach | NativeAOT 10.0 | 90.230 ns | 11.91 |      48 B |          NA |

Это вполне ожидаемо - АОТ код просто не может быть оптимизирован на столько же, на сколько может быть оптимизирован код JITa. Отсюда же и аллокации, и скорость.

Для исправления скорости интересно было бы попробовать скомпилировать с собранным заранее профилем - я в этом совершенно не имею опыта, но чисто логически - может и поможет. Может быть и с некоторыми аллокациями поможет за счёт guarded devirtualization.

Если проведёте эксперимент - поделитесь результатом, пожалуйста, для общего развития.

Спасибо за прогон! Результат ожидаемый: деабстракция из статьи — это в первую очередь работа JIT в рантайме (escape analysis + девиртуализация по горячему профилю), а NativeAOT компилирует всё заранее и такой информации не имеет. Поэтому бокс энумератора в интерфейсном foreach там остаётся как есть — те самые 48 B.

Частично помочь может профилируемая сборка: собрать профиль через dotnet-pgo и скормить его AOT-компиляции — часть девиртуализации станет возможной статически. Если руки дойдут проверить — очень интересно увидеть цифры. Возможно, добавлю раздел про AOT в статью или сделаю отдельную часть.

если они так накосячили с интерфейсом, то они могут решить это все через атрибуты.

например создать атрибут, который будет подсказывать компилятору, какой тип там всегда возвращантся. и пометить им метод getenumerator.

а атрибут форс инлайна у них уже есть. пометить метод который в инумераторе в конце вызывается и все.

и интерфейс не поменяется и все оптимизации на этапе il кода будут работать

Выглядит не очень, похоже на шаманские пляски с угадыванием и подгонкой результата вместо научного подхода.

Цитаты:

Вот и вся теория. Никакого new в коде нет, а аллокация есть. 

...

// Тип переменной - List<int>. foreach берет метод №1.

смотрим метод №1

// 1. Публичный. Возвращает struct КАК ЕСТЬ. Его берет foreach,// когда тип переменной - List<T>. Ноль аллокаций.public List<T>.Enumerator GetEnumerator() => new List<T>.Enumerator(this);

Видим явный new.

Итого TLDR:

  1. Абстракции не бесплатны, в данном случае абстракция "итератор"

  2. От погоды, данных и версии NET (и еще AOT) зависит, сможет ли компилятор оптимизировать абстракции.

  3. Такие мелочи разработчиков энтерпрайзных ЯП не волнуют, ИТОГО -> забить.

«Видим явный new». Видим — только это new у структуры. List<T>.Enumerator — struct, а new для value type ничего в куче не выделяет: значение создаётся на месте, на стеке (ECMA-335, I.8.9.7). «Явный new» и «аллокация» — разные вещи.

Лучший пруф — в таблицах самой статьи: этот new List<T>.Enumerator(this) исполняется в каждом замере, а колонка Allocated у foreach по List/Dictionary — ноль. Если бы struct-new означал кучу, там стабильно было бы 40–48 B. Аллокация появляется не от new, а от боксинга — когда этот struct заворачивают в object при возврате через интерфейс.

«Никакого new в коде нет» — сказано про пользовательский код: в вашем foreach его действительно нет, а Allocated ненулевой. Откуда — статья разбирает до машинного кода.

Про «подгонку»: методика одна на все кейсы — BenchmarkDotNet, Allocated, дизасм через DOTNET_JitDisasm четыре машины, код на гите. Прогоните — получите те же цифры. Назовите конкретный «подогнанный» результат — проверим вместе.

С пунктами 1 и 2 вашего TLDR не спорю — статья ровно их и показывает цифрами. А «забить или нет» зависит от того, сколько таких foreach в секунду крутит ваш прод; для этого в статье абсолютные числа.

ECMA-335 I.8.9.7

Value type definition Not all types defined by a class definition are object types (see §I.8.2.3); in particular, value types are not object types, but they are defined using a class definition. A class definition for a value type defines both the (unboxed) value type and the associated boxed type (see §I.8.2.4). The members of the class definition define the representation of both:

1. When a non-static method (i.e., an instance or virtual method) is called on the value type, its this pointer is a managed reference to the instance, whereas when the method is called on the associated boxed type, the this pointer is an object reference. Instance methods on value types receive a this pointer that is a managed pointer to the unboxed type whereas virtual methods (including those on interfaces implemented by the value type) receive an instance of the boxed type.

2. Value types do not support interface contracts, but their associated boxed types do.

3. A value type does not inherit; rather the base type specified in the class definition defines the base type of the boxed type.

4. The base type of a boxed type shall not have any fields. 5. Unlike object types, instances of value types do not require a constructor to be called when an instance is created. Instead, the verification rules require that verifiable code initialize instances to zero (null for object fields).

Не вижу здесь утверждаемого "а new для value type ничего в куче не выделяет".

А new скорее всего вызывается здесь, как и написано в коде GetEnumerator

; .NET 8, Tier1, optimized using Dynamic PGO ; всего 460 байт кода call [r11]IEnumerable`1[int]:GetEnumerator() mov gword ptr [rbp-0x30], rcx ; ссылка на объект-энумератор В КУЧЕ

Полностью надо листинги смотреть, конечно.

По параграфу вы правы, я тупанул:(. Там такой I.8.9.7 про определение value type. Нужный — ECMA-335 III.4.21 (newobj): для value type new obj создаёт само значение и кладёт его на стек — не ссылку. И отмечено, что boxed-экземпляр через new obj создать нельзя вовсе. Boxed-версия типа (I.8.2.4) создаётся только инструкцией box (III.4.1). Так что «new List<T>.Enumerator(this)» в исходнике GetEnumerator — это создание значения, кучи в нём нет по спецификации.

Теперь про ваш листинг — и это ключевое. Вы цитируете call [r11]IEnumerable`1[int]:GetEnumerator() — это интерфейсный кейс из статьи (метод №2, SumOverInterface): вызов идёт виртуально через IEnumerable, метод обязан вернуть объект — и вот тут struct-энумератор боксится, потому в комментарии и написано «в куче». Это не «new положил struct в кучу», это боксинг при возврате через интерфейс — ровно по статье.

А в прямом foreach (метод №1, тип переменной List<T>) — второй листинг и колонка Allocated: ноль. Тот же new в том же GetEnumerator исполняется миллионы раз за прогон — и ни байта в куче. Если бы new struct аллоцировал, Allocated не мог бы быть нулевым: BenchmarkDotNet считает аллокации точным счётчиком GC, а не оценкой.

«Полностью надо листинги смотреть» — согласен, потому они и лежат в репозитории целиком: disasm_net8.txt и disasm_net10.txt, оба кейса. Там видно и боксинг в интерфейсном пути на .net 8, и его исчезновение на .net 10.

Я оценил уровень понимания.

Моя самая первая фраза была верной.

Sign up to leave a comment.

Articles