Обновить
32K+
22

Пользователь

77,1
Рейтинг
17
Подписчики
Отправить сообщение

Сори, статья чуть более, чем средняя. Есть LOH, он же Large Object Heap, проще говоря это то, куда попадают в net объекты, которые больше 85 000 байт (не всегда!). Отдельная куча (вам стоит изучить), собирается только вместе со вторым поколением (поколения гарбедж коллектора и он же сборщик мусора, мастхев). Если надо будет помощь, всегда рад помочь, в том числе статьями, если надо

Спасибо! По работе часто нужны оптимизации, ну и просто хобби — почему бы и нет:)

Спасибо! В выводах он был, а в этом списке я его пропустил. Добавил.

Да, решарпер это ловит. У майкрософта тоже есть правило под этот случай — CA1066 (Implement IEquatable when overriding Equals), но по умолчанию оно выключено: в доке прямо "Enabled by default in .NET 10 — No". Так что штатно, без решарпера или включения анализаторов, проблема остаётся незамеченной. https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1066

А незачем — это одно и то же. IndexOf(char) в исходниках это одна строчка: SpanHelpers.IndexOfChar(ref _firstChar, value, Length), цитата из String.Searching.cs есть в статье. AsSpan().IndexOf(c) приходит в тот же SpanHelpers, машинный код идентичный, разница ноль. AsSpan даёт выигрыш только на перегрузке со строкой, а там дефолт CurrentCulture, а спан ординал. Но о таком я отдельно писал и решение — указать StringComparison.Ordinal.

Спасибо за коммент, тут и я частично не прав, не все учел и вышло не очень.

Я только отметил, что первая причина частота, а не ширина регистра. Кеш тут вообще ни при чём ведь 64 килобайта целиком сидят в L1/L2 на обеих машинах.

Частота объясняет большую часть разрыва, но не весь, тут понятно. Десктоп на такт кинет порядка 22 байт, но ксеон порядка 13 — а потому он медленнее даже с поправкой на гигагерцы, и не забываю про вдвое широкие регистры, конечно.

Остаток — задержки масочных сравнений AVX-512 (vpcmpeqw в k-регистр + kortest).

Для чистоты прогоню на том же ксеоне с DOTNET_PreferredVectorBitWidth=256 — машина та же, кеш тот же, меняется только ширина вектора. Цифры добавлю чуть позже.

 

Забыл:( Теперь есть:)

Так StringZilla внутри — тот же ручной симд, что и в BCL, только отдельной либой. Мой тезис и подтверждает :) Доберётесь до авэиска 512го кидайте цифры, интригует

Да, C# разрешает менять i внутри тела цикла — ваш пример с i += 100 полностью валиден, компилятор его пропустит без единого предупреждения. И именно поэтому проверка границ у != обязана остаться: раз индекс может прыгнуть куда угодно, JIT не может доказать, что он не перескочит Length мимо точного равенства.

С < это неважно — там условие i < Length само по себе ловит любой выход за край на следующей итерации. А != ловит только точное совпадение, и если вы прыгнули через него — цикл убежит за массив. Проверка границ — это ровно та страховка, которая не даст ему убежать.

Так что тут не «а вдруг», а точная причина, по которой JIT осторожничает и проверку не снимает. С < он это доказать может и проверку убирает, с != — нет.

Про unsafe тоже верно: там проверок границ нет вообще — и за выход отвечаете уже вы сами.

Это ровно то, о чём issue #84697 в dotnet/runtime: там разработчики JIT прямо обсуждают, что для восходящего цикла с != нужен анализ, доказывающий монотонность индекса, а пока его нет — проверка остаётся. И это не только про C#: в треде отмечают, что ту же форму не дожимают и другие компиляторы, включая LLVM, ровно по той же причине.

По параграфу вы правы, я тупанул:(. Там такой 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.

«Видим явный 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 в секунду крутит ваш прод; для этого в статье абсолютные числа.

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

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

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

  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.

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

В тестах есть такой вариант кода и его результат. Кратко говоря так делать не выгодно по скорости (в любой случае) и памяти (если новый делать). Вот все это и тестировалось ради понять, кто и почему лучше из наиболее распространенных вариантов

Действительно, не то смотрел. Да, все работает и так и так

В данных тестах у меня private const string Content, поэтому не меняет. Если сделать не константой, то да, меняет. Заменил и переснял результаты.

Спасибо за комментарий. Поправил и обновил результаты тестов

Исходная не меняется, поэтому нужно работать с результатом в виде chars, что можно проверить вызвав метод просто так.

Спасибо, поправил

1

Информация

В рейтинге
95-й
Зарегистрирован
Активность