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, ровно по той же причине.
Прогресс! К NET 20 дойдут до loop unrolling.
Помним {$R-} F/ !
loop unrolling много лет есть в JIT, но включается для совсем простых случаев. мало развернуть цикл, нужно еще что-то сделать с тем что получилось чтобы был какой-то профит с этого. А тут в дело вступает модель памяти которая не позволит легально что-то засимдовать в большинстве случаев.
Там, где нужно гарантированно избавиться от 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 или ставят сами себе челенджы
Бенчмаркая проверки границ: фикс, который шёл 8 лет, и проверка, которая жива до сих пор