Комментарии 46
Кодировал алгоритмы на C# и заметил что изменение позиции объявления переменной влияло на время исполнения на 20%, условно
int a=0;
int b=0;
менял на
int a=0, b=0;
Мне это очень не понравилось, по сути JIT пропускал кучи оптимизаций только из-за особенностей трассировки моего кода. Это эпикфейл MS.
Не знаю, как у вас там в С#, но для микроконтроллеров в классическом СИ в любой приличной IDE (IAR, KEIL, и т.д.) компиляторы имеют несколько режимов оптимизации уже давным-давно. И как раз такие вещи они прекрасно обрабатывают. Даже могут несколько строк кода выбрасывать из прошивки, если эти строки не имеют смысла. Во время отладки с непривычки разработчик может быть удивлён тем, что отладчик пропускает некоторые строки и даже блоки кода типа простых циклов со счётчиком, который нигде больше не используется (например, для задержки на 1 млн тактов).
Уверен, что для С# оптимизация настраивается от и до.
Уверен, что для С# оптимизация настраивается от и до.
Нет. Это по многим причинам не так. По большому счёту основная разница идёт между debug и release режимами - между ними разница значительна и, конечно, тестирование производительности в debug не стоит выполнять.
Компиляция C#->IL почти не настраивается и почти не оптимизирует (ну разве что на уровне вывода типов немного). Это, похоже, принципиальный подход.
JIT (IL в машинный код) тоже особо не настраивается - это скорее нужно для отладки самого движка .Net, чем для прикладных программистов. Причём JIT ограничен в оптимизациях - во-первых жёсткие требования к времени компиляции, во-вторых некоторые вещи нельзя оптимизировать "как в C" потому что нельзя по контракту.
В итоге .NET может выявить какие-то константные вычисления, некоторые peephole оптимизации, ограниченно может заинлайнить, может выкинуть неисполнимые ветки кода. Но, например, не делает оптимизацию tail recursion - скорее всего для сохранения возможности размотки стека при исключениях, возможно по этой же причине хвостовой call никогда не преобразует в jump. Расчёт констант и выражений, оптимизация циклов и глубина инлайнинга тоже очень далека от -O3 С/С++. Но не скажу, что всё совсем плохо (пользуясь случаем, скажу спасибо @Nagg за его вклад в дело оптимизации движка).
Важно отметить, что это не "C# плохой", это скорее "C# другой".
Но, например, не делает оптимизацию tail recursion - скорее всего для сохранения возможности размотки стека при исключениях, возможно по этой же причине хвостовой call никогда не преобразует в jump.
Вообще говоря, инструкция tail. в IL есть. Другое дело, что компилятор C# очень тяжело заставить её применять. А вот F# и Nemerle активно используют хвостовую рекурсию, если это возможно (например, нет обработки исключений с вызовом рекурсивной функции внутри).
Ну что же, век живи - век учись. Спасибо за наводку. Я не знал о её существовании. В любом случае, C/C++ компилятору проверить допустимость хвостовой рекурсии обычно проще (и поэтому она происходит).
Иногда под дотнетом очень интересные оптимизации встречаются. Например, тот же F# часто вообще рекурсию в цикл преобразует.
Пример с факториалом
Было:
let fact n =
let rec fact_r n acc =
match n with
| 1 -> acc
| n -> fact_r (n - 1) (acc * n)
if n > 1 then fact_r n 1 else 1
Стало:
_.fact_r@2(Int32, Int32)
L0000: push ebp
L0001: mov ebp, esp
L0003: lea eax, [ecx-1]
L0006: test eax, eax
L0008: ja short L000e
L000a: mov eax, edx
L000c: pop ebp
L000d: ret
L000e: imul edx, ecx
L0011: mov ecx, eax
L0013: jmp short L0003
_.fact(Int32)
L0000: cmp ecx, 1
L0003: jle short L0010
L0005: mov edx, 1
L000a: call _.fact_r@2(Int32, Int32)
L000f: ret
L0010: mov eax, 1
L0015: ret
Эквивалентный код на C#:
internal static int fact_r@2(int n, int acc)
{
while (true)
{
if (n == 1) return acc;
acc *= n;
n = n - 1;
}
}
public static int fact(int n)
{
if (n > 1) return fact_r@2(n, 1);
return 1;
}
Только не инструкция, а префикс инструкции.
Вот только в своё время изменение обработки этого префикса в JIT-компиляторе вызвало некоторое удивление у некоторых людей.
Не скажу за детали, но суть была в том, что некоторый человек ориентировался на то, что tailcall игнорируется и некоторый ресурсоёмкий сервис в облаке падает со stack overflow, а после обновления рантайма он внезапно стал использовать хвостовую рекурсию и не падал, а пользователю за работу сервиса был выставлен немалый счёт (это в телеге в pro.net как-то обсуждалось)
Хотелось бы увидеть какой-то тест, не один год в C#, никогда не замечал подбного.
Так как JIT о C# ничего не знает, это надо на iL смотреть.
Я думаю было бы хорошо иметь полный пример
... и желательно на https://sharplab.io/
Нет особой разницы в тем что я написал выше, так было:
static void strfind3(byte[] s, byte[] su)
{
int ju = 0;
int c = 0;
int m = 0;
int sl = s.Length;
int sul = su.Length;
int eof = sl - sul;
int sp = 0;
// всякое...
}
этот код при обработке файла в 900М отрабатывал за 2400 мс, а этот код:
static void strfind3(byte[] s, byte[] su)
{
int ju = 0, c = 0, m = 0, sp = 0, j, sl = s.Length, sul = su.Length, eof = s.Length - sul;
// всякое
}
уже за 1800 мс. Была однозначная повторяемость результатов.
Позднее в другой программе тоже игрался с объявлением переменных, там код
int i = 0, compares = 0, matches = 0, sul = substr.Length, sl = str.Length;
менялся на
int compares = 0, matches = 0, sul = substr.Length, sl = str.Length;
int i = 0;
и выполнение ВСЕЙ программы замедлялось на 40%.
Я пишу без ООП, короткие программы, я не разработчик, проверяю какие-то алгоритмы и т.п. и для меня такое поведение - нонсенс, так как еще с Turbo C я прекрасно помню как сам компилятор оптимизирует код.
и желательно на https://sharplab.io/
я не занимаюсь публикацией своих программ.
Наличие этой разницы уже многое говорит
Ни о чём не говорит. Это даже на синтетическом примере не даст и 3% разницы.
Сейчас мы НЕ доказываем что код меняется на 40% производительности, мы показываем суть моего треда - при синонимичном изменении кода меняется состав инструкций ЦПУ в конечном коде. Важно именно это, а не сколько там скушает mov.
если не всю, то я уже вам дал всё необходимое. а без всей программы эффект может и не наблюдаться. вон ниже уже начали петь песню про неправильное тестирование, но это не то совсем.
Да, упустил и не проверил внимательно - как раз к тому, что от небольшой семантически значимой разницы может измениться код. Это отличие было в и коде комментария. Там еще и j
определена была.
Я пишу без ООП, короткие программы, я не разработчик, проверяю какие-то алгоритмы и т.п. и для меня такое поведение — нонсенс,
Возможно, вы просто не умеете замерять производительность.
Ох, любимый комментарий XD Если его не пишут, то считайте день прошёл зря.
Вы подразумеваете, что если программа А в одних и тех же условиях выполняется то 1 минуту, то - 5, то виноват не код программы (язык, компилятор, оптимизации), а процедура замера? (надеюсь вам же не приходит в голову, что два подряд объявления int не являются синонимичными записи с одним int???)
Это как на олимпийских играх для бегунов на 400 метров поставить две пары фотоэлементов и регистрировать старт и финиш, а потом кто-то скажет, что есть квантовая суперпозиция и ваш способ замера не учитывает искривление пространства-времени.
Да, измерять производительность не так просто. Это не про "искривление пространства-времени". Судя по разнице и кейсу, скорее всего у вас разница проявляется на debug режиме. Могу ошибаться, но вы же так и не предоставили конкретные кейсы и замеры. И в этом случае у вас вашим бегунам надо одному после каждых 10 метров отмечаться в бланке о пробегании 10 метров, а второму - раз в 50 метров. В такой процедуре уже неважно, чем вы измеряли - фотофинишем или ручным секундомером, потому что скорость бега на 400 метров вы не измеряете.
База с которой в принципе можно начинать замерять производительность - поставить максимально в одинаковые "стандартные" условия измеряемые кейсы (в .NET стал стандартным инструмент BenchmarkDotNet, в JVM - JMH). Следующая часть - зафиксировать и описать окружение. Следующая - сформулировать гипотезу (в данном случае - что однострочное объявление лучше многострочного). Потом - проверить гипотезу. Потом, если она подтверждается замерами - максимально изолировать причину и уменьшить влияние других факторов. По результатам уже можно было бы поднять issue в проекте dotnet runtime.
Да, бывают какие-то отклонения от этой процедуры, например, если бага в инлайнинге jit, то она как раз НЕ проявляется в debug, а проявляется в release (я такой кейс находил). Но это всё равно примерно тот же сценарий (только код был одинаковый, а окружения отличались ровно на опцию debug/release).
Это не дебаг, что очевидно.
Мне не нужен специальный инструмент для измерений если я имею статистику и повторяемость результата при изменении кода. Но если вам так легче:
// * Summary *
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1706 (21H2)
[Host] : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
DefaultJob : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
| Method | Mean | Error | StdDev |
|-------------- |--------:|---------:|---------:|
| 1 | 1.237 s | 0.0054 s | 0.0048 s |
| 2 | 2.282 s | 0.0085 s | 0.0079 s |
Что это изменило? XD
Так полный код предоставьте, на котором такое поведение наблюдается, иначе это все равно голословно.
1) NDA
2) Мы не в суде, я сообщил информацию к сведению, вы можете её проигнорировать
2) Мы не в суде, я сообщил информацию к сведению, вы можете её проигнорировать
Ну я, в свою очередь, постарался донести свое мнение до других пользователей, которые прочитают ваше сообщение.
1 лучше почисти метрики от контекста, часто nda содержит размытое определение выдачи конфиденциальной информации
У вас не идентичный код, что выше привели вы. У вас не только изменение в объявлении, но и само присваивание для eof совершенно подругому описано. Ваша проблема может состоять в том, что для вычисления eof переменная S уже ушла из кеша процессора например
Вот смотрите, у вас ОС Windows, вы её написали. Кода на 1 млн страниц, а производительной ВСЕЙ системы определяется тем как именно в самой первой строке кода произошло присваивание eof, не скорость запуска системы, не скорость выполнения какого-то участка кода, а скорость работы вообще всей ОС. Вам не кажется, что ваше открытие тут ничего не значит?
Неужели все это стоит учитывать?
Голословное утверждение, ничем не подкрепленное.
у меня нет задачи сделать вас своим адептом, но !!!! все, кто пользуется C# не на уровне "Hello World!" знают, что ассемблерный код может очень сильно меняться при несущественных изменениях в программе, просто я лично не ожидал, что эти изменения могут быть практически фатальными для производительности. Я привык, что компилятор умнее меня с точки зрения оптимизации, я это видел и вижу при работе с MOS 6502, с 8088/80386, во всяком случае я не жду от них такой подставы.
Однако оба эти подхода привели бы к критическому изменению, поскольку этот метод является частью публичного API.
Разве сейчас контракт функции не поменялся (пусть и не так заметно)? До этого возвращался reference на копию, а сейчас на оригинал. Гипотетически можно представить клиента, который менял полученное правило и при этом никак не влиял на других клиентов. Теперь это изменится
А нельзя просто написать r[i].Match(...)? Зачем это переменная вообще нужна?
Конечно, если как выше написано в комментарии, чтобы отдать копию, то тогда автор статьи рукожоп (не переводчик, переводчик молодец).
Как сделать программу на Go быстрее на 42%, изменив один символ