Эффективность

Содержание

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

Эффективность часто предполагает компромиссы. Например, стремление заставить программу выполняться быстрее часто приводит к увеличению ее размеров. Такая взаимосвязь особенно сильно проявляется в тех случаях, когда используется линейный код с целью исключения накладных расходов на вызов функций. С другой стороны, желание уменьшить размер программы за счет замены линейного кода вызовами функций иногда приводит к замедлению выполнения программы. Аналогичная ситуация складывается и в отношении эффективного использования дискового пространства. Достижение этой цели часто подразумевает более компактное представление данных, которое может замедлить доступ к данным, из-за накладных расходов, вызванных дополнительной обработкой процессором таких данных. Такие и подобные им компромиссы эффективности могут вызвать чувство полного разочарования и неудовлетворенности, особенно среди неспециалистов и конечных пользователей, которые не могут понять, почему одно влияет на другое. К счастью, существует несколько методов, которые могут одновременно и ускорить выполнение программы, и уменьшить ее размер.

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

Операции увеличения и уменьшения


Уже стало традицией при обсуждении эффективности использования языка С почти всегда начинать с операторов увеличения (инкремента) и уменьшения (декремента). В некоторых ситуациях применение этих операторов помогает компилятору сгенерировать более эффективный объектный код. Рассмотрим, например, следующую последовательность операторов:

/* первый метод */
a = a + b;
b = b + 1;

/* второй метод */
a = a + b++;

В обоих вариантах приведенного примера программы переменной а присваивается значение суммы а+b, а затем значение b увеличивается на единицу. Однако зачастую второй метод приводит к более эффективной программе, поскольку компилятор имеет возможность избежать выполнения дополнительных (а значит, избыточных) инструкций загрузки и записи в память при обращении к переменной b. Другими словами, b не надо будет загружать в регистр процессора дважды — в первый раз при суммировании с переменной а, а второй раз при ее инкрементировании. Хотя некоторые компиляторы будут автоматически оптимизировать оба варианта, и сгенерируют одинаковый объектный код, это не может считаться само собой разумеющимся для всех остальных случаев. В общем случае, аккуратное использование операторов ++ и может увеличить скорость выполнения программы, и в то же время уменьшить ее размер. Поэтому, старайтесь находить именно такие решения, в которых используются эти операторы.

Применение регистровых переменных


Один из наиболее эффективных методов повышения скорости выполнения программного кода состоит в применении к переменным спецификатора класса памяти register. Хотя применение регистровых переменных эффективно и в других случаях, они исключительно хорошо подходят для управления циклом. Ведь регистровые переменные хранятся так, что для обращения к ним требуется минимальное время. Целочисленные переменные, к которым применен спецификатор класса памяти register, хранятся, как правило, в регистре центрального процессора. Это имеет огромное значение, поскольку скорость выполнения критически важных циклов программы, часто определяет итоговую скорость ее выполнения. Например:

for(i=0; i<MAX; i++) {
  /* что-нибудь выполнить */
}

В этом фрагменте осуществляется многократная проверка и установка нового значения переменной i. В частности, всякий раз, когда выполняется цикл, значение i проверяется, и если оно не достигло конечного значения, то инкрементируется. Поскольку этот процесс повторяется много раз, время обращения к i оказывает существенное влияние на скорость выполнения всего цикла.

Однако не только управляющие переменные циклов, но любые другие переменные, используемые внутри тела цикла, могут заслуживать модификации с помощью спецификатора класса памяти register. Например:

for(i=0; i<MAX; i++) {
  sum = a + b;
  /* ... */
}

В данном примере при каждом повторном выполнении тела цикла осуществляется обращение к переменным sum, а и b. К тому же, при каждой итерации цикла переменной sum присваивается новое значение. Таким образом, время, требуемое для обращения к этим переменным, также влияет на общую производительность приведенного цикла.

Спецификатор класса памяти register можно применить к любому количеству переменных. Однако ограничения, обусловленные архитектурой центрального процессора, приведут к тому, что в пределах одной функции большинство компиляторов смогут оптимизировать время доступа всего лишь для нескольких переменных. В общем случае всегда можно рассчитывать на то, что в любой момент времени в регистрах центрального процессора обязательно найдется место, по крайней мере, для двух целочисленных переменных. Дополнительно можно использовать и другие виды быстрой памяти, например кэш-память, но возможности ее применения также ограничены. Поскольку применить оптимизацию к каждой переменной, модифицированной с помощью спецификатора класса памяти register, скорее всего, будет невозможно, язык С позволяет компилятору игнорировать спецификатор класса памяти register и просто разрешает использовать переменную обычным способом. К тому же это правило позволяет код, созданный для одной среды, скомпилировать для другой среды, в которой емкость запоминающего устройства с быстрой выборкой меньше. Поскольку объем запоминающего устройства с быстрой выборкой всегда ограничен, лучше тщательно отбирать только те переменные, реализация быстрого доступа к которым в максимальной степени оптимизирует программу.

Указатели вместо индексации массива


В некоторых случаях индексацию массива целесообразно заменить арифметическими операциями над указателями (например, приращением). Такая замена может привести к уменьшению размера кода и повышению его быстродействия. Например, рассмотрим следующие два фрагмента кода, которые выполняют одну и ту же работу:

Индексация массива       Приращение указателя
                           p = array;
  for(;;) {                for(;;) {
    a = array[t++];          a = *(p++);
    .                        .
    .                        .
    .                        .
  }                        }

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

Будьте внимательны. В случаях, когда индекс массива вычисляется с помощью сложной формулы, или когда действия над указателями «затеняют» смысл программы, необходимо использовать индексацию массива. Как правило, лучше слегка снизить производительность работы программы, чем пожертвовать ее четкостью и ясностью. К тому же, разница в производительности между индексацией массива и вычислением указателя может оказаться незначительной в случае использования оптимизирующих компиляторов или в случае, если программа предназначена для работы в различных средах или с различными процессорами.

Применение функций


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

При компиляции функции для хранения передаваемых функции параметров (если они, конечно, имеются), а также любых локальных переменных, используемых этой функцией, компилятор С использует стек. А когда происходит вызов функции, то в стек помещается еще и адрес возврата в вызывающую программу. (Это позволяет продолжить выполнение подпрограммы с того места, из которого была вызвана функция. Точнее, с команды, следующей за вызовом функции.) Когда функция возвращает управление, этот адрес и все локальные переменные и параметры будут удалены из стека. Процесс заталкивания (записи в стек) этой информации обычно называют вызывающей последовательностью (calling sequence), а процесс выталкивания данных из стека называется возвращающей последовательностью (returning sequence)[1]. Эти последовательности выполняются в течение определенного промежутка времени, иногда довольно значительного.

Чтобы лучше понять, каким образом вызов функции может замедлить программу, рассмотрим приведенные ниже два фрагмента программного кода:

Вариант 1                              Вариант 2
  for(x=1; x<100; ++x) {                 for() {
    t = compute(x);                           t = fabs(sin (q)/100/3.1416);
  }                                         }
  
  double compute(int q)
  {
     return fabs(sin (q)/100/3.1416);
  }

Хотя в каждом из циклов осуществляется одна и та же операция, Вариант 2 выполняется быстрее, потому что благодаря применению линейного кода были исключены накладные расходы на выполнение вызывающей и возвращающей последовательностей. (Другими словами, код, реализующий функцию compute(), просто дублируется внутри цикла, а не вызывается.)

Чтобы лучше понять, в чем состоят непроизводительные издержки, связанные с вызовом функций, давайте рассмотрим ассемблерный код инструкций, которые необходимы для вызова и возврата из функции. Как вам, наверное, известно, многие С-компиляторы имеют специальную опцию для создания файла с ассемблерным кодом; при использовании этой опции создается ассемблерный, а не объектный код. Применение этой опции позволяет исследовать код, созданный компилятором. В последующем примере мы исследуем файлом с ассемблерным кодом, созданным с помощью Visual C++ при включенной опции -Fa. Мы внимательно рассмотрим этот файл, чтобы воочию убедиться в том, какой код генерируется для вызывающих и возвращающих последовательностей. Возьмем следующую программу:

int max(int a, int b);

int main(void)
{
   int x;

   x = max(10, 20);

  return 0;
}

int max(int a, int b)
{
  return a>b ? a : b;
}

Для нее получится следующий ассемблерный код. Вызывающие и возвращающие последовательности отмечены в листинге комментариями, начинающимися со знака звездочка (*); эти комментарии добавлены автором книги. Как вы можете убедиться, вызывающие и возвращающие последовательности занимают по объему довольно значительную часть программного кода.

        TITLE      test.с
        .386Р
include listing.inc
if  @Version gt  510
.model FLAT
else
_TEXT   SEGMENT PARA  USE32 PUBLIC 'CODE'
_TEXT   ENDS
_DATA   SEGMENT DWORD USE32 PUBLIC 'DATA'
_DATA   ENDS
CONST   SEGMENT DWORD USE32 PUBLIC 'CONST'
CONST   ENDS
_BSS    SEGMENT DWORD USE32 PUBLIC 'BSS'
_BSS    ENDS
_TLS    SEGMENT DWORD USE32 PUBLIC 'TLS'
_TLS    ENDS
FLAT    GROUP  _DATA,  CONST,  _BSS
         ASSUME  CS: FLAT,  DS: FLAT,  SS: FLAT
endif
PUBLIC  _max
PUBLIC  _main
_TEXT   SEGMENT
_x$  =  -4
_main   PROC NEAR
; File  ex2.с
; Line  4
         push    ebp
         mov     ebp, esp
         push    ecx
; Line  7
; **************************************************
; Это начало вызывающей последовательности.
; **************************************************
         push    20                                 ;00000014Н
         push    10                                 ;0000000aH
         call    _max
; **************************************************
;
; **************************************************
; Следующая строка относится к возвращающей последовательности.
; **************************************************
         add     esp, 8
         mov     DWORD PTR _x$[ebp], eax
; Line  9
         xor     eax, eax
; Line  10
         mov     esp, ebp
         pop     ebp
         ret     0
_main   ENDP
_a$  =  8
_b$  =  12
_max    PROC NEAR
; Line  13
; **************************************************
; Дополнительный фрагмент вызывающей последовательности.
; **************************************************
         push    ebp
         mov     ebp, esp
         push    ecx
; **************************************************
; Line  14
         mov     eax, DWORD PTR _a$[ebp]
         cmp     eax, DWORD PTR _b$[ebp]
         jle     SHORT $L48
         mov     ecx, DWORD PTR _a$[ebp]
         mov     DWORD PTR -4+[ebp], ecx
         jmp     SHORT $L49
$L48:
         mov     edx, DWORD PTR _b$[ebp]
         mov     DWORD PTR -4+[ebp], edx
$L49:
; **************************************************
; Возвращающая последовательность.
; **************************************************
         mov     eax, DWORD PTR -4+[ebp]
; Line  15
         mov     esp, ebp
         pop     ebp
         ret     0
_max    ENDP
_TEXT   ENDS
END

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

Однако из приведенных рассуждений совсем не следует, что с целью сокращения времени выполнения в программах нужно использовать всего лишь несколько, но зато громадных функций. Ведь это далеко не самый лучший стиль программирования. Во-первых, в абсолютном большинстве случаев незначительный выигрыш во времени, получаемый за счет отказа от вызова функций, не имеет столь большого значения по сравнению с весьма ощутимой потерей структурированности. К тому же, только этой проблемой дело не ограничивается. Замена функций, которые используются несколькими подпрограммами, линейным кодом приводит к непомерному «разрастанию» программы, поскольку одинаковый код повторяется несколько раз. Не забывайте, что подпрограммы были созданы отчасти для того, чтобы более эффективно использовать память. Фактически именно поэтому считается, что уменьшение времени выполнения программы ведет к увеличению ее размеров. И наоборот, уменьшение размера программы обычно приводит к замедлению скорости ее выполнения.

Чтобы применить линейный код как метод ускорения программы, в компиляторе, совместимом с С99, предусмотрено зарезервированное слово inline для создания встраиваемых функций (при компиляции вызов такой функции заменяется ее кодом). Используя это слово, вам не придется вручную каждый раз дублировать исходный код каждой функции. Если же ваш компилятор со стандартом С99, то чтобы получить аналогичный результат, следует использовать макросы с параметрами в тех местах, где имеется такая возможность. Естественно, макросы с параметрами не предоставляют той гибкости, которой обладают функции, в описании которых применяется слово inline.

----------
[1]Имеются в виду, конечно, последовательности команд.