методы оптимизации программного кода
Статей с подобным заголовком достаточно много, поэтому постараюсь избежать банальных тем. Надеюсь, что даже опытный разработчик найдёт здесь что-то полезное для себя. В данной статье будут рассмотрены только простые механизмы и подходы оптимизации, которые позволят применить их, затратив минимум усилий. И эти изменения не увеличат энтропию вашего кода. В статье не будет уделено внимания, что и когда нужно оптимизировать, эта статья скорее о подходе к написанию кода в целом.
1. ToArray vs ToList
Согласитесь, очень типовой код для промышленных проектов. Но что в нём не так? IEnumerable интерфейс возвращает коллекцию, по которой можно «пробежаться», данный интерфейс не предполагает того, что мы можем добавлять/удалять элементы. Соответственно, нет необходимости заканчивать LINQ выражение приведением к List’у (ToList). В данном случае, предпочтительнее будет приведение к Array (ToArray). Так как List является обёрткой над Array, а все дополнительные возможности, предоставляемые этой обёрткой, мы срезаем интерфейсом. Массив потребляет меньше памяти, а доступ к его значениям быстрее. Соответственно, зачем платить больше. В описанном выше примере, интерфейс IEnumerable введён лишь для наглядности. Если в коде, вы собираетесь вызвать ToList для LINQ выражения, убедитесь, действительно ли вам нужна функциональность именно List’a. С одной стороны эта оптимизация не существенная, как говорят «оптимизация на спичках», но это не совсем так. Дело в том, что в типовом приложении, в котором сервисы возвращают модели для слоя представления, таких вызовов ToList может быть мириады.
Предвижу комментарий о том, что Array и List будут работать не эквивалентно в случае обращения к коллекции из разных потоков, с возможностью её изменения. Это действительно так. Но если вы, как разработчик, рассматриваете такой сценарий, то с высокой степенью вероятности, вам уже не подходят ни Array, ни List.
2. Параметр «путь к файлу» не всегда лучший выбор для вашего метода
При разработке API избегайте сигнатур методов, которые на вход получают путь к файлу (для последующей обработки вашим методом). Вместо этого предоставляйте возможность передать на вход массив байт или в крайнем случае Stream. Дело в том, что со временем, ваш метод может быть применён не только к файлу с диска, но и к файлу, переданному по сети, к файлу из архива, к файлу из базы данных, к файлу содержание которого сформировано динамически в памяти и т. д. Предоставляя метод с входным параметром «путь к файлу» вы обязываете пользователя вашего API предварительно сохранить данные на диск, чтобы потом прочесть их снова. Это бессмысленная операция критически влияет на производительность. Диск – крайне медленная штука. Для удобства, вы можете предоставить метод с входным параметром «путь к файлу», но внутри всегда используйте публичный перегруженный метод с массивом байт или stream’ом на входе. Есть «маркер», который может помочь найти лишние операции записи/чтения диска, попробуйте найти в вашем проекте использование стандартных методов: Path.GetTempPath() и Path.GetRandomFileName() (из System.IO). С высокой степенью вероятности, вы встретите workaround вышеописанной проблемы или похожей.
Внимательный и опытный читатель заметит, что в некоторых случаях, запись на диск может наоборот улучшить производительность, например, если мы имеем дело с очень большими файлами. Это действительно так, и это необходимо учитывать, но предполагаю что это редкая ситуация со специфичной реализацией.
3. Избегайте использования потоков в качестве параметров и возвращаемого результата ваших методов
В чём здесь проблема… когда мы получаем поток из некоторого «чёрного ящика», мы должны держать в голове его состояние. Т.е. открыт ли поток? Где находится маркер чтения/записи? Может ли измениться его состояние независимо от нашего кода? Если поток объявлен как базовый класс Stream, мы даже не владеем информацией, какие операции над ним доступны. Всё это решается дополнительными проверками, а это дополнительный код и издержки. Также, неоднократно сталкивался с ситуацией, когда, получая Stream из некоторого неочевидного метода, разработчик предпочитал перестраховаться и «перегнать» данные из него в полностью контролируемый новый локальный MemoryStream. Хотя, исходный поток мог быть вполне безопасным. Может даже это и был уже любезно подготовленный для чтения MemoryStream. Иногда может доходить до абсурда – внутри метода, массив байт кладётся в MemoryStream, далее данный MemoryStream возвращается как результат метода, объявленного как базовый Stream. Снаружи этот Stream оборачивается новым MemoryStream’ом и далее ToArray() возвращает массив байт, который изначально у нас и был. Точнее это уже будет его очередная копия. Ирония в том, что внутри и снаружи нашего метода код вполне корректный. По-моему, этот пример не из головы, а встречался где-то в коммерческом коде.
В итоге, если у вас есть возможность передавать / получать «чистые» данные, не используйте для этого потоки – не создавайте капканов, для тех, кто будет этим пользоваться. Если же в вашем приложении уже есть передача / возврат потоков, проанализируйте их использование на основе вышеизложенного.
4. Наследование enum’ов
Данная оптимизация банальная, её знают все, даже студенты. Но из моего опыта, ей крайне редко пользуются. Итак, по умолчанию enum наследуется от int. Однако его можно наследовать от byte, который вмещает 256 значений (или 8 «flaggable» значений). Что почти всегда покрывает функциональность «среднего» enum’а. Минимальное изменение в коде и все значения вашего enum’а занимают меньше памяти навсегда. Ниже иллюстрация бенчмарка по заполнению коллекции значениями enum’ов, наследуемых от int и byte.
5. Ещё пару слов о классах Array и List
Следуя логике, итерирование по массиву всегда эффективнее итерирования по List’у, так как List это обёртка над массивом. Также, следуя логике, «for» всегда быстрее «foreach», так как «foreach» делает много действий, требуемых реализацией интерфейса IEnumerable. Здесь всё логично, но неверно! Давайте посмотрим на результаты бенчмарка:
Дело в том, что для итерирования по массиву, «foreach» не использует реализацию IEnumerable. В этом частном случае выполняется максимально оптимизированное итерирование по индексу, без проверки на выход за границы массива, так как конструкция «foreach» не оперирует индексами, соответственно у разработчика нет возможности «накосячить» в коде. Такое вот исключение из правил. Поэтому, если в каком-то критичном участке кода вы заменили использование «foreach» на «for» ради оптимизации – вы выстрелили себе в ногу. Обратите внимание, это актуально только для массивов. На StackOverflow есть несколько веток, где обсуждается это особенность.
6. Всегда ли поиск через хеш-таблицу оправдан?
Все знают, что хеш-таблицы очень эффективны для поиска. Но часто забывают, что цена за быстрый поиск — медленное добавление в хеш-таблицу. Что из этого следует? Для того чтобы использование хеш-таблицы было оправданным, необходимо, чтобы кол-во элементов хеш-таблицы было не менее 8 (примерно). И чтобы кол-во операций поиска было хотя бы на порядок больше кол-ва операций добавления. В противном случае используйте коллекцию попроще. Качество хеш-функции внесёт свои коррективы в эффективность, но смысл от этого не измениться. На моей практике был случай, когда самым «узким местом» в нагруженном коде был вызов метода Dictionary.Add(). Ключом был обычный string, небольшой длины. Воспоминание об этом и стало триггером к написание этого пункта. Для иллюстрации, пример очень плохого кода:
Может что-то подобное встречается и в вашем проекте?
7. Встраивание методов
Код разбит на методы чаще всего по 2-ум причинам. Обеспечить повторное использование кода и обеспечить декомпозицию, когда одна задача разбивается на несколько подзадач. Для человека так проще. Inlining – это обратный процесс декомпозиции, т.е. код метода встраивается в то место, где метод должен вызываться, в итоге мы экономим на стеке вызовов и передаче параметров. Я никоим образом не рекомендую всё «запихивать» в один метод. Но те методы, которые мы могли бы теоретически «заинлайнить» можно пометить соответствующим атрибутом:
8. Оценочный Capacity
У меня (и надеюсь, у большинства разработчиков тоже) выработан рефлекс: проинициализировал коллекцию – задумался, можно ли для неё задать Capacity. Однако, далеко не всегда заранее известно точное кол-во элементов коллекции. Но это не повод игнорировать этот параметр. Например, если, рассуждая о том, какое кол-во элементов будет в вашей коллекции, вы предполагаете размытое «пару тыщ» это уже повод задать Capacity равное 1000. Немного теории, например, для List по умолчанию Capacity = 16, для того чтобы только дойти до 1000, система сделает 1008 (16 + 32 + 64 + 128 + 256 + 512) лишних копирований элементов и создаст 7 временных массивов на откуп следующему вызову GC. Т.е. вся эта работа выполнится впустую. Также, в качестве Capacity никто не запрещает использовать формулу. Если размер вашей коллекции оценочно равен одной трети другой коллекции, можно задать Capacity равное otherCollection.Count / 3. При установке Capacity стоит хорошо понимать диапазон возможного размера коллекции и насколько его значение плотно распределено. Всегда есть вероятность навредить, но при правильном использовании, оценочный Capacity даст вам хороший выигрыш.
9. Всегда конкретизируйте ваш код
Активно используйте (на первый взгляд, необязательные) ключевые слова C#, такие как: static, const, readonly, sealed, abstract и т.д. Естественно, там, где они имеют смысл. Причём здесь производительность? Дело в том, что чем более детально вы опишете компилятору свою систему, тем более оптимальный код он сможет сгенерировать. Внимательный и опытный читатель может заметить что, например ключевое слово sealed никак не влияет на производительность. Сейчас это действительно так, но в следующих версиях всё может измениться. Дайте компилятору и виртуальной машине шанс! Бонусом получите, выявление многих ошибок неправильного использования вашего кода на этапе компиляции. Общее правило: чем более чётко система описана, тем оптимальнее результат. Судя по всему, с людьми также.
Несколько полезных инструментов
В качестве заключения
Если каждый 10-ый прочитавший статью, применит вышеуказанные подходы к своему текущему проекту (или критической его части), а также будет придерживаться этих подходов в будущем, то вместе мы сможем спасти целый лес! Т.е. сэкономленные ресурсы компьютерных систем, в виде электричества, полученного от сжигания древесины, останутся неиспользованными. В данном случае «лес» это лишь некий эквивалент. Вероятно, странное заключение получилось, но, надеюсь, вы прониклись мыслью.
P.S. Обновление на основании комментариев к посту
Если enum, наследуемый от byte, используется как член класса (структуры), а не отдельно, то экономии памяти может и не быть. Из-за выравнивания занимаемой памяти всех членов класса (структуры). Эта особенность в статье упущена. Тем не менее, потенциальный выигрыш лучше его отсутствия, так как помимо занимаемой памяти enum’ы ещё и используются. Поэтому пункт 4, по-прежнему актуален, но с данной важной оговоркой.
Спасибо KvanTTT и epetrukhin за конструктивные комментарии по этим вопросам.
Также, как заметил Taritsyn, оптимизация на этапе JIT-компиляции для ключевого слова «sealed» всё же существует. Но, это только подтверждает все тезисы 9-го пункта.
Полагаю, учтены все конструктивные замечания по содержанию статьи. Я очень рад этим замечаниям. Так как я сам, как автор, узнал для себя тоже что-то новое.
4.8.3 Методы оптимизации кода
Оптимизацию можно выполнять на любой стадии генерации кода, начиная от завершения синтаксического разбора и вплоть до последнего этапа, когда порождается код результирующей программы. Если компилятор использует несколько различных форм внутреннего представления программы, то каждая из них можетбыть подвергнута оптимизации, причем различные формы внутреннего представления ориентированы на различные методы оптимизации. Таким образом, оптимизация в компиляторе может выполняться несколько раз на этапе генерации кода.
Принципиально различаются два основных вида оптимизирующих преобразований:
— преобразования исходной программы (в форме ее внутреннего представления в компиляторе), не зависящие от результирующего объектного языка;
— преобразования результирующей объектной программы.
Первый вид преобразований не зависит от архитектуры целевой вычислительной системы, на которой будет выполняться результирующая программа. Обычно он основан на выполнении хорошо известных и обоснованных математических и логических преобразований, производимых над внутренним представлением программы.
Второй вид преобразований может зависеть не только от свойств объектного языка, но и от архитектуры вычислительной системы, на которой будет выполняться результирующая программа. Так, например, при оптимизации может учитываться объем кэш-памяти и методы организации конвейерных операций центрального процессора. В большинстве случаев эти преобразования сильно зависят от реализации компилятора и являются «ноу-хау» производителей компилятора. Именно этот тип оптимизирующих преобразований позволяет существенно повысить эффективность результирующего кода.
У современных компиляторов существуют возможности выбора не только общего критерия оптимизации, но и отдельных методов, которые будут использоваться при выполнении оптимизации.
Методы преобразования программы зависят от типов синтаксических конструкций исходного языка. Теоретически разработаны методы оптимизации для многих типовых конструкций языков программирования.
Оптимизация может выполняться для следующих типовых синтаксических конструкций:
линейных участков программы;
вызовов процедур и функций;
других конструкций входного языка.
Во всех случаях могут использоваться как машинно-зависимые, так и машинно-независимые методы оптимизации.
4.8.4 Оптимизация линейных участков программ
Линейный участок программы — это выполняемая по порядку последовательностьопераций, имеющая один вход и один выход. Чаще всего линейный участок содержит последовательность вычислений, состоящих из арифметических операций и операторов присвоения значений переменным.
Для операций, составляющих линейный участок программы, могут применяться следующие виды оптимизирующих преобразований:
— удаление бесполезных присваиваний;
— исключение избыточных вычислений (лишних операций);
— свертка операций объектного кода;
Удаление бесполезных присваиваний заключается в том, что если в составе линейного участка программы имеется операция присвоения значения некоторой произвольной переменной А с номером i, а также операция присвоения значения той же переменной А с номеромj,i 38 / 48 38 39 40 41 42 43 44 45 46 > Следующая > >>
Тут вы можете оставить комментарий к выбранному абзацу или сообщить об ошибке.
Оптимизация кода
Оптимизируя исполняемый файл, можно добиться баланса между быстрым выполнением и небольшим размером кода. В этом разделе обсуждаются механизмы, предоставляемые Visual Studio для оптимизации кода.
Возможности языка
В следующих разделах описываются некоторые функции оптимизации в C/C++.
Прагмы и ключевые слова оптимизации
Список ключевых слов и прагм, которые можно использовать в коде для повышения производительности.
Параметры компилятора, упорядоченные по категориям
Список параметров компилятора /O, которые влияют на скорость выполнения или размер кода.
Декларатор ссылки rvalue: &&
Ссылки rvalue поддерживают реализацию семантики перемещения. Если для реализации библиотек шаблонов используется семантика перемещения, производительность приложений, использующих эти шаблоны, может значительно повыситься.
Прагма optimize
Если оптимизированный раздел кода вызывает ошибки или замедление, можно использовать прагму optimize, чтобы отключить оптимизацию для этого раздела.
Заключите код между двумя прагмами, как показано ниже:
Рекомендации по программированию
При компиляции кода с оптимизацией можно заметить дополнительные предупреждения. Такое поведение является ожидаемым, так как некоторые предупреждения относятся только к оптимизированному коду. Обращайте внимание на эти предупреждения, чтобы избежать многих проблем с оптимизацией.
Парадоксально, но оптимизация программы для ускорения может привести к снижению скорости выполнения кода. Это обусловлено тем, что некоторые оптимизации для скорости увеличивают размер кода. Например, функции встраивания устраняют издержки, вызванные вызовами функций. Однако встраивание слишком большого объема кода может сделать программу настолько большой, что число ошибок страниц виртуальной памяти увеличится. Таким образом, выигрыш в скорости, полученный при исключении вызовов функций, будет компенсирован обменом памятью.
В следующих разделах рассматриваются оптимальные методы программирования.
Рекомендации по оптимизации критичного по времени кода
Улучшенные методы программирования могут повысить производительность. В этом разделе предлагаются приемы программирования, которые помогут обеспечить удовлетворительную производительность критичного по времени кода.
Рекомендации по оптимизации
Общие рекомендации по эффективной оптимизации приложения.
Отладка оптимизированного кода
Поскольку оптимизация может изменить код, созданный компилятором, рекомендуется выполнить отладку приложения и оценить его производительность, а затем оптимизировать код.
В следующих разделах представлена информация о том, как отладить сборки выпуска.
В следующих разделах содержатся сведения о том, как оптимизировать сборку, загрузку и выполнение кода.
Оптимизация C/C++ кода
Данная статья является вольным переводом статьи Optimizing C++/Code optimization/Faster operations. Оригинал найти можно по ссылке. Первая часть лежит здесь.
Часть 2
Префиксный или постфиксный оператор
Префиксный оператор предпочтительнее постфиксного. При работе с примитивными типами префиксные и постфиксные арифметические операции, вероятно, будут иметь одинаковую производительность. Однако с объектами, операторы постфикса могут заставить объект создать собственную копию, чтобы сохранить свое начальное состояние (которое должно быть возвращено в результате операции), а также вызвать побочный эффект операции. Рассмотрим следующий пример:
Поскольку операторы постфикса обязаны возвращать неизмененную версию значения, которое увеличивается (или уменьшается) — независимо от того, используется ли результат на самом деле — скорее всего, он сделает копию. Итераторы STL (например) более эффективны при изменении с помощью префиксных операторов.
Встроенные функции
Если вы не используете параметры компилятора для оптимизации всей программы, чтобы компилятор мог встраивать любую функцию, то есть смысл перенести некоторые функции в заголовочные файлы как inline функции, то есть объявить их встроенными.
Если верить руководству (например компилятора gcc «5.34 An Inline Function is As Fast As a Macro»), то inline функция выполняется (так же быстро как макрос) быстрее чем обычная из-за устранения служебных вызовов, но стоит учитывать, что не все функции будут работать быстрее, а некоторые функции, объявленные как inline способны замедлить работу всей программы.
Целочисленное деление на постоянную
Когда вы делите целое число (которое является положительным или равным нулю) на константу, преобразуйте целое число в unsigned.
Например, если s — целое число со знаком, u — целое число без знака, а C — выражение с постоянным целым числом (положительное или отрицательное), операция s / C медленнее, чем u / C, а s% C медленнее, чем u% C. Это проявляется наиболее явно, когда С — степень двойки, но, все же, при делении знак стоит учитываться.
Кстати, преобразование из signed в unsigned ничего не будет нам стоить, поскольку это только другая интерпретация одних и тех же битов. Следовательно, если s — целое число со знаком, которое будет использоваться в дальнейшем, как положительное или ноль, вы можете ускорить его деление, используя следующие выражения: ( unsigned ) s / C и ( unsigned ) s% C.
Использование нескольких массивов вместо полей структуры
Вместо обработки одного массива совокупных объектов параллельно обрабатывайте два или более массива одинаковой длины. Например, вместо следующего кода:
следующий код может быть быстрее:
Используя эту перегруппировку, «a», «b» и «c» могут обрабатываться командами обработки массива, которые значительно быстрее, чем скалярные инструкции. Эта оптимизация может иметь нулевые или неблагоприятные результаты для некоторых архитектур.
Еще лучше перемежать массивы:
PS: Учтите, что каждый случай нужно тестировать, а не оптимизировать преждевременно.
Оптимизация C/C++ кода
Данная статья является вольным переводом статьи Optimizing C++/Code optimization/Faster operations. Оригинал найти можно по ссылке.
Предисловие
Зачастую некоторые элементарные операции, вроде бы, концептуально похожие, могут разительно отличатся по быстроте выполнения. Поэтому, при написании кода, нужно уметь выбирать более быстрые иструкции. Конечно, в некоторых компиляторах уже есть встроенная оптимизация под конкретные процессоры, поэтому иногда данные методы бесполезны, но даже в этом случее не плохо знать возможность оптимизации кода. Стоит заметить, что некоторые технологии могут даже ушудшить производительность, поэтому и оптимизировать надо с умом.
Часть 1
Расположение членов в структурах/классах
Распологать члены в структурах/классах, стоит таким образом, чтобы наиболее используемые переменные находились в первых 128 байтах, а затем отсортировать от самого большого члена до самого маленького.
Предположим, что есть некая структура:
Если предположить, что в, приведенной выше, структуре член msg используется только для сообщений об ошибках, в то время как другие члены используются для вычислений, то вы можете ускорить вычисление, изаменив порядок членов в структуре на следующий:
На некоторых процессорах адресация элемента более эффективна, если его расстояние от начала структуры меньше 128 байт. В первом примере для адресации полей d и i, с использованием указателя на начало структуры, требуется смещение не менее 400 байт, тогда как во втором, смещения составляет всего несколько байтов, что позволяет использовать более компактные инструкции.
Если посчитать размер структуры, 1 (bool) + 7 (padding) + 8 (double) + 2 (short) + 2 (padding) + 4 (int), то мы получим 24 байта, 9 из котороых будут использованы для выравнивания, что относительно размера структуры довольно много. Давайте перепишем эту же структуру таким образом:
Снова проведем подсчет — 8 (double) + 4 (int) + 2 (short) + 1 (bool) + 1 (padding). Теперь наша структура займет 16 байт. Сортировка устранила ненужные вызванные, и поэтому мы получили более компактную структуру.
Преобразование типа с плавающей точкой в целочисленный
Язык C++ не предоставляет примитивную операцию округления чисел с плавающей точкой. Самым простым методом преобразования числа с плавающей точкой x в ближайшее целое число n будет оператор:
К сожалению, на ряде процессоров такое выражение компилируется в очень медленный машинный код. Некоторые процессоры имеют конкретные инструкции для округления чисел. В частности, семейство Pentium имеет командный fistp, который, как и в следующем коде, дает гораздо более быстрый, хотя и не совсем эквивалентный код:
Если этот результат является допустимым или даже желательным, и есть возможность использовать встроенную сборку, используйте этот код. Правда, переносимость на другие семейства процессоров оставляет желать лучшего.
Работа с битами
У величить производительность можно с помощью использования знаний работы с битами, здесь есть коллекция хаков, которые помогут в этом. Часть из приведенных там «трюков», на самом деле, уже используются некоторыми компиляторами, другие же полезны для решения редких проблем, а некоторые, вообще, полезны только на определенных платформах.
Размер ячеек массива
Случается, что размер (можно проверить с помощью sizeof) небольших ячеек массивов или векторов является степенью двойки, а размер больших ячеек — нет. Прямой доступ к ячейке массива выполняется путем умножения индекса на размер ячейки, который является константой. Если второй коэффициент этого умножения равен степени двойки, то такая операция выполняется намного быстрее, так как осуществляется в виде битового сдвига. Аналогично, в многомерных массивах.
Получить нужный размер можно путем добавления неиспользуемых полей к структурам и неиспользуемых ячейек в массивы. Например, если каждая ячейка представляет собой 3-кортеж объектов с плавающей точкой, достаточно добавить четвертый фиктивный объект с плавающей точкой в каждую ячейку.
Однако, при доступе к ячейкам многомерного массива, в котором константа достигает достаточной большой степени двойки, то вы рискуете конфликт данных(data cache contention), что может замедлить вычисление в 10 и более раз. Это происходит только тогда, когда ячейки массива превышают определенный размер, который зависит от кэша данных, но составляет от 1 до 8 КБ. Следовательно, в случае, если алгоритм должен обрабатывать массив, чьи ячейки имеют или могут иметь размер равный 1024 байта, то стоит определить, имеет ли место конфликт данных, чтобы попробовать его избежать.
Например, матрица размером 100 x 512 float — это 100 массивов по 512 эелементов. Каждая ячейка массива первого уровня имеет размер 512 x 4 = 2048 байт, и поэтому она подвержена риску конфликта данных.
Чтобы обнаружить конкуренцию, достаточно добавить еще одну ячейку ( float ) в каждый массив из массива последнего уровня, но при этом обрабатывать те же ячейки, что и раньше, и измерять, существенно ли сокращается время обработки (по меньшей мере на 20% ). Если это так, то вы должны обеспечить более стабильную работу такого улучшения. Для этой цели вы можете использовать один из следующих методов: