Pull to refresh

Comments 16

Уже не помню точно как там в c#, возможно внутри цикла переопределить индекс, или компилятор не пропустит? Типа такого:

for (int i = 0; i != src.Length; i++) {
  sum += src[i];
  i += 100;
}

Хотя, даже если и не пропустит, наверняка через unsafe можно поменять.

А если в теории поменять можно, то и проверка границ необходима, ибо != не будет гарантировать ничего. С другой стороны, переопределением индекса на какой-нибудь max int можно поломать вообще любой цикл. Так что, наверное, не так уж и необходима проверка.

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

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

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

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

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

loop unrolling много лет есть в JIT, но включается для совсем простых случаев. мало развернуть цикл, нужно еще что-то сделать с тем что получилось чтобы был какой-то профит с этого. А тут в дело вступает модель памяти которая не позволит легально что-то засимдовать в большинстве случаев.

Расскажи это создателям GCC и LLVM.

А то они цикл из статьи разворачивают в SIMD уже при O2

PS. Чтобы дважды не писать, C# это не про оптимизации вообще, язык для энтерпрайза, более менее производительный.

Там, где нужно гарантированно избавиться от bounds checking - ref var, MemoryMarshal.Get*Reference + Unsafe.Add.

При желании, с их помощью можно соорудить цикл с обратным счётчиком и при этом "прямым" доступом к данным - экономим инструкции на загрузке предельного значения в регистр и сравнении счётчика с регистром.

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

Там, где нужно гарантированно избавиться от bounds checking - ref var, MemoryMarshal.Get*Reference + Unsafe.Add.

Это всё будет требовать unsafe {} контекст.

При желании, с их помощью можно соорудить цикл с обратным счётчиком и при этом “прямым” доступом к данным - экономим инструкции на загрузке предельного значения в регистр и сравнении счётчика с регистром.

Большинство обычного вида циклов не нуждаются в этом, jit вставит оптимальный код как есть

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

Сильно зависит от платформы, но в целом на процентов 10-15% производительности можно расчитывать + стабилизация бенчей

Это всё будет требовать unsafe {} контекст.

Будет.

Но и определение unsafe context изменится, и как по мне - так к лучшему, и вполне можно будет держать его компактным и при желании скрытым в приватном скоупе.

Большинство обычного вида циклов не нуждаются в этом, jit вставит оптимальный код как есть

Другой бы спорил, а я не стану.

Но что умеешь - за плечами не носить, как говорится. Лично я был бы рад, если бы в своё время имел возможность начитаться о всякого рода трюках чисто про запас - меньше пришлось бы тратить времени на "изобретения", когда они реально понадобились.

Но что умеешь - за плечами не носить, как говорится. Лично я был бы рад, если бы в своё время имел возможность начитаться о всякого рода трюках чисто про запас - меньше пришлось бы тратить времени на “изобретения”, когда они реально понадобились.

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

Я пытатся сказать, что небезопасность, сомнительность с т.з. выигрыша или надежды на ненужность в будущем тех или иных трюков - не повод игнорировать знания о них. Это, как минимум, расширяет кругозор, углубляет понимание низкоуровневых процессов и экономит немало времени в ситуации, когда срочно нужно что-нибудь ускорить, а как - неясно.

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

то что вчера надо было костылить сегодня уже работает как надо

При всём моём восхищении от развития НЕТ рантайма, я совершенно уверен, что многие проблемы никогда не смогут быть решены на этом уровне. Так что костылить - вполне себе необходимый навык ;)

как и ваш перевод статьи об ансейф коде, с которой я во многом не согласен как в части рекомендаций

даже интересно стало - с чем конкретно? :-) а то вроде исключительно база

Нужно освежить. Завтра.

Итак, статья.

Во-первых, я читал её давно, когда она была опубликована. Сейчас, когда увидел её в списке ваших - вспомнил, в том числе то, что был "во многом не согласен" и особенно - с достаточно безапеляционным тоном рекомендаций. При этом, запомнил её как полезную.
Сейчас перечитываю, отвечу по пунктам.

Что однозначно хорошо - напоминание про работу GC и связанные риски неуправляемых указателей (fixed/pinned контексты и риски утекания указателей за их пределы).

Что, возможно, ещё более важно - напоминание о хранении невалидных указателей в управляемых ref var - типа ref arr[-1] или ref arr[arr.Length + 1].
Об этом вполне возможно никогда и не задуматься, сам исправлял подобные собственные ошибки несколько лет назад.

С чем не согласен:

"9. Удаление проверок границ (bounds check)"
Там все советы можно свести к "не обходите проверки границ". Что, как по мне - плохой совет, потому, что если уж встал вопрос о том, что стоит от них избавиться - значит нужно избавиться.
Есть масса вариантов, которые никакие эвристики JITа не ускорят: random access, доступ к элементам в "сегменте" массива, границы которого вычисляются сложным алгоритмом или вообще хранятся в условном Dictionary<SegmentKey, (int offset, int length)>, обработка в не-заинлайненом методе.
При этом совет использовать Debug.Assert для выявления ошибок - это чисто проверка того, что ваши тесты запущенные в Дебаг-режиме написаны для happy pass.
Я бы советовал подготовить необходимые данные и карты/рейнжи, убедиться, что они валидны - а потом использовать их без лишних проверок.

10. Объединение доступа к памяти (Memory access coalescing)
Тоже самое. Напрмер, у меня был тип, содержавший список структур, состоявших из множества полей примитивных типов (от byte до long), тип использовался как ключ в Dictionary. Угадаете, как были написаны GetHashCode и Equal? - да, как GetHashCode/Equal над спаном байтов начиная с адреса list[0] и до list[count].
И это был самый быстрый и эффективный вариант, остальные работали в разы медленнее.
О чём я бы отдельно напомнил в этом разделе - о паддингах, которые могут содержать мусор.

12. Бинарная (де)сериализация структур с паддингами или non-blittable членами
С non-blittable членами всё понятно, их нужно обрабатывать отдельно.А вот с blittable, и даже с паддингами - почему бы и нет?
Я бы здесь отдельно напомнил об endianness - должны совпадать для источника и получателя, но в целом - вполне себе оптимизация для контролируемых случаев.

13. Null управляемые указатели
Вполне валидный случай, как и null для nullable переменных - почему нет?
Я бы советовал проверять с Unsafe.IsNullRef и пользоваться со спокойной совестью.

15. Буферы фиксированного размера (Fixed-size buffers)
"буферы фиксированного размера могут иметь ненулевое содержимое в определенных сценариях" как риск, так и экономия на очистке - главное об этом помнить.

16. Передача непрерывных данных как указатели + длины
Тут вообще не понимаю проблемы.
Самый дешевый способ передать (или хранить в ref struct) буфер для bounds unchecked обработки.
Не забываем, что new Span/AsSpan сам по себе проверяет границы - а нам, может быть, уже и не надо? - разве сто создавать спан с помощью MemoryMarshal.CreateSpan(ref T reference, int length), ха-ха. И на каждом обращении к индексеру (не в "удачном" цикле) - тоже проверяет.
Дальше, на х64 спан занимает 16 байт, тогда как размер без паддинга - 12. Для единственного инстанса ок, но представим, что вы храните несколько ссылок на буферы в ref struct, которая кроме этого содержит и множество полей - вполне себе оверхед при передаче по значению.

18. Ручной IL код (например, System.Reflection.Emit и Mono.Cecil)
Тут всё просто: сложно в написании и отладке - но раз надо - значит надо.

19. Неинициализированные локальные переменные [SkipLocalsInit] и Unsafe.SkipInit
По мне так сильно преувеличенные риски. Во-первых, GC-переменные JIT всё равно обнуляет. Во-вторых - C# всё равно не позволяет обратиться к неинициализированной переменной.
Отдельно о Unsafe.SkipInit: представьте, у вас тяжелая структура со множеством полей. Ваш конструктор в любом случае инициализируют все их них, так зачем вам предварительная очистка?
Это не говоря о том, что как минимум НЕТ8 (а то и 9), если у вас в структуре overlapped поля (FieldOffset) - то он ухитрялся эмитить MSIL для индивидуальной очистке каждого из этих полей. Не смотрел, что было в асме и не следил, как работает 10/11 - но такое вот было.

Примерно так.

В целом, достаточно было бы объяснить поведение рантайма во всех этих аспектах и свазать "соблюдайте осторожность и сохраняйте здравый смысл" :)
А безапеляционные "советы" выглядят странно.

При этом статья полезная, то, что опубликована и на русском - ещё лучше.
А работа по переводу - так и вообще выще всяких похвал.

(9) удаление проверок границ - популярная причина CVE когда девелопер думает что он самый умный оптимизируя на 2 наносекунды какой-то код от скуки

(10) тоже самое только со вкусом редковоспроизводимых багов из-за сломанной атомарности (джит мержит доступы к памяти там где это ничего не сломает сам)

(12) сериализация секретов со стека в паддинги и отправка по сети. десериализация untrusted input из массива байт - это прям хорошо известные CVE, внутри мсфт прям есть список забаненных либ которые такое делают

(13) там в статье написано что с этим не так

(16) опять ковбой-стайл удаление проверок границ, ну удачи вашему продакшну.

Дальше, на х64 спан занимает 16 байт, тогда как размер без паддинга - 12. Для единственного инстанса ок, но представим, что вы храните несколько ссылок на буферы в ref struct, которая кроме этого содержит и множество полей - вполне себе оверхед при передаче по значению.

тут вообще не понял что конкретно вы экономите на передаче спана vs byref+length. передача byref+length это как минимум - два 8 байтовых регистра

(19) “C# всё равно не позволяет обратиться к неинициализированной переменной.”

во-первых, вполне позволяет. во-вторых, неинициализированные переменные вполне классифицируются как memory safety issue в любом языке

короче у вас претензии в стиле “не мешайте мне стрелять в ногу”, а смысл статьи - криминализировать байтое*лю, как это сделано в раст, где за любой unsafe код насмехаются, а не “блин, ну ты крут конечно, понимаешь как под капотом всё работает”

Ну вот именно это, да: байтоебля, ковбои, "за любой unsafe код насмехаются", "удачи вашему продакшну" - именно в этом статья и... на любителя.
Что статья, что ответ - очень aligned, иф ю ноу вот ай мин.

У меня проф.деформация, я повидал слишком много очень плохого кода, авторы которого свято верили что делают благое дело микрооптимизируя код. в dotnet/runtime написали новую либу, 99% safe код. в одном узком месте просился Unsafe.As чтобы убрать оверхед от ковариантного каста. Угадайте из-за какого кода в итоге в этой библиотеки был официальный CVE? И это написано экспертами, теми кто все нюансы рантайма знает по долгу службы, а на деле чаще всего unsafe код пишется скучающими программистами которые борятся с imaginary scaling issues или ставят сами себе челенджы

Sign up to leave a comment.

Articles