Автор: Sergey Teplyakov
Давать советы об эффективности тех или иных языковых конструкций довольно сложно, поскольку мало в каком языке есть конструкции с заведомо плохой эффективностью. Обычно разные языковые конструкции предназначены для решения хоть и похожих, но несколько разных задач. Например, цикл for в C# работает только с индексируемыми коллекциями, поэтому сравнивать его с циклом foreach в общем случае некорректно.
Но самая главная проблема появляется тогда, когда в совете об эффективности той или иной фичи не объясняется ситуация, в которой этот совет является применимым. Вроде бы, цитату Кнута/Хоара о преждевременной оптимизации знают все, но продолжают давать советы об эффективности в ультимативной форме.
Вот и сегодня мне встретился такой совет, который ретвитнули несколько человек в моей ленте. Совет с самой мудростью, доступной лишь посвященным, но якобы полезной любому .NET разработчику:
Great high-performance tips for .NET developers pic.twitter.com/EepLub5cl9
Morten Nielsen (@dotMorten) May 29, 2016
Проблема #1. Отсутствие контекста
Как обычно, когда дается совет о производительности, должен быть DISCLAIMER: Не меняйте свой код исходя из нижележащих советов. Задайте перф-цель, измерьте текущую производительность, если есть проблема запустите профайлер и найдите проблемные участки и в них, возможно, воспользуйтесь моими советами.
Например, совет по поводу LINQ-а валиден. Но ведь он валиден в 0.001% кода. В кишках Розлина LINQ и правда запрещен, как и запрещен он в кишках Решарпера и в критических кусках нового билд-движка, над которым работает моя команда. Но запрещен он не во всех этих проектах, а лишь в нескольких участках, наиболее требовательных к производительности.
Вообще, совет с LINQ-ом особенно плох, поскольку C# не предоставляет средств с соизмеримой выразительностью. Если не особо разобравшийся в теме разработчик начнет следовать этому совету в своем чудо ЫнтЫрпрайз проекте, то он существенно испоганит код, не получив никакой выгоды.
В перф-критикал коде вполне возможно использование кастомных коллекций (что делают каждый из перечисленных мною проектов), поскольку накладные расходы стандартных по памяти будут неприемлемыми. В перф-критикал коде действительно выделение памяти на енумератор может быть проблемным. Но в абсолютном же числе случаев, LINQ будет вполне ОК, и не нужно от него отказываться!
Проблема #2. Совет по поводу лямбд: Do not use lambdas, as they cause allocations
Совет очень плох, поскольку он ну, очень плох. Я могу понять совет подобного рода: остерегайтесь использования делегатов, поскольку они имеют несколько большую цену вызова чем экземплярный метод и неосторожное их создание может привести к ненужным выделениям памяти. Но, как всегда, пожалуйста, запустите профайлер и убедитесь, что проблема есть.
Я для нашего проекта портировал компилятор TypeScript на C# (пока не скажу зачем), и вместе с кодом парсера и сканнера, перевел и код обхода дерева. Метод обхода рекурсивный и он вызывает себя для всех дочерних узлов. При этом, в теле метода был такой код: public static void WalkTree(Node node, Action<,Node>, callBack){// Создавем функцию обхода Action<,Node>, walker = ProcessNode,// Используем ее внутри! walker(node),}private static void ProcessNode(Node node){ }
Поскольку Method Group Conversion, который происходит при создании переменной walker всегда приводит к аллокации нового делегата, то профайлер показал, что 10% процентов времени обхода занимает создание делегата. Замена этого кода на лямбда-выражение устранило проблему, поскольку лямбда-выражения могут быть закешированы, если они не захватывают внешнего контекста: public static void WalkTree(Node node, Action<,Node>, callBack){// Создавем функцию обхода Action<,Node>, walker = (n) =>, ProcessNode(n),// Используем ее внутри! walker(node),}
Да, компилятор сможет закешировать лишь незахватывающие лямбда-выражения, но ведь совет говорит, что лямбды плохо ибо аллокации. А это в общем случае не совсем верно, а в некоторых случаях не верно совсем,)
Проблема #3. Beware of boxing!
И далее:
A common case for that is passing structs to a method that takes an interface as a parameter. Instead, make the method take the concrete type (by ref) so that it can be passed without allocation.
Тут есть несколько моментов. Во-первых, мне сложно сказать, насколько статистика верна и что проблема с упаковкой действительно зачастую проявляется именно из-за передачи структур в методы, которые принимают интерфейсы. Во-вторых, фраза не точная, ведь если обобщенный метод принимает интерфейс через ограничение, то упаковки не будет:
public static void NoAllocations<,T>,(T comparable) where T : IComparable{ }
Ну и в третьих, в совете говорится о передаче структур по ссылке, что может сильно запутать читателя кода, поскольку семантика передачи чего-то по значению и по ссылке очень и очень разные.
Проблема #4. Prefer structs to classes whenever you can.
Этот совет тоже оторван от реальности и является опасным. В предыдущем совете о передаче аргументов метода кратко упоминается передача по ссылке. Хотя объяснения там нет, причина передачи по ссылке заключается в том, что это избавляет от копирования структур при вызове метода, что может быть существенным для структур большого размера.
Но проблема заключается в том, что структуры по своей природе копируются еще во многих других случаях, и даже тогда, когда мы с вами можем и не подозревать, и там, где ключевое слово ref всунуть нельзя:
- Возвращаемое значение метода
- Свойство
- Readonly поле (!!!)
Причем последнее неоднократно являлось причиной лулзов, причем даже среди весьма опытных и даже именитых товарищей (читайте подробнее у Джона Скита - Micro-Optimization: The Surprising Inefficiency Of Readonly Fields). И это я не говорю о том, что вам могут понадобиться мутабельный типы, которые реализовывать в виде структур категорически не рекомендуется.
(да, и тут нужно не забывать, что значимые типы не сильно дружат с ООП, а значит ваш дизайн может серьезно пострадать, если вы ненароком последуете этому совету).
Проблема #5. Ложь и провокации
которая заключается в последнем совете: Avoid foreach loops on everything except raw arrays. Each call on a non-array allocates an enumerator. Prefer regular for loops whenever possible.
Так вот, енумераторы всех коллекций в BCL являются структурами (да, изменяемыми структурами!). Так что foreach на них не будет приводить к аллокациям! Точка!
Foreach с массивами работает быстрее, просто потому что там вообще не используются никакие итераторы. Компилятор C# для цикла foreach с массивами просто берет и генерирует цикл for. Именно поэтому он в микробенчмарке будет быстрее, чем foreach на списке.
К тому же, существует сверх малое число сценариев, когда накладные расходы перебора элементов будут играть хоть какую-то роль по сравнению с телом самого цикла. Если вдруг, этот случай настанет, то нужно оптимизировать именно его, а не переводить все циклы на for!
К тому же, если перебор элементов массива быстрее, чем перебор элементов других коллекций, означает ли это, что нам нужно избегать любых других коллекций кроме массивов? Если да, то как быть, если размер заранее неизвестен?
Вместо заключения
Производительность тема сложная. Она сложна сам по себе, но она становится еще сложнее, когда разработчике в проекте начинают подходить к ней фанатично. Я как-то немного устал разгребать низкокачественный низкоуровневый код, который был написан разработчиками, слишком буквально следовавшими подобным советам. В результате всегда получался код, который тяжело читать, понимать и развивать. А значит и находить, и исправлять в нем настоящие проблемы с производительностью всегда было очень сложно.
Давайте думать о производительности, но давайте не следовать культу карго и не портить дизайн решения вслепую. Если уж придется его портить, то пусть это будут те самые 2-3% кода, где это действительно нужно.
P.S. Ну и, на всякий случай, это не только я, кому показался этот набор советов сомнительным. Вот мнение ПМ-а .NET-а:
@dotMorten Unfortunately, some advice is actually incorrect/misleading :-( Performance is a vast topic and clear cut advice is hard.
Immo Landwerth (@terrajobst) May 29, 2016