Главная страница

Руководство по стилю программирования и конструированию по


Скачать 7.6 Mb.
НазваниеРуководство по стилю программирования и конструированию по
Дата18.05.2023
Размер7.6 Mb.
Формат файлаpdf
Имя файлаCode_Complete.pdf
ТипРуководство
#1139697
страница75 из 104
1   ...   71   72   73   74   75   76   77   78   ...   104
ГЛАВА 26 Методики оптимизации кода
611
26.3. Изменения типов данных
Изменение типов данных может быть эффективным способом сокращения кода и повышения его быстродействия. Проектирование структур данных в этой кни- ге не рассматривается, но умеренные изменения реализации отдельных типов данных также могут повышать производительность. Ниже описано несколько способов оптимизации типов данных.
Использование целых чисел вместо чисел
с плавающей запятой
Сложение и умножение целых чисел, как правило, выпол- няются быстрее, чем аналогичные операции над числами с плавающей запятой. Например, циклы выполняются быст- рее, если индекс имеет целочисленный тип.
Пример неэффективного цикла с индексом
с плавающей запятой (Visual Basic)
Dim x As Single
For x = 0 to 99
a( x ) = 0
Next
Сравните этот код с аналогичным циклом, в котором явно используется целочис- ленный индекс:
Пример эффективного цикла с целочисленным индексом (Visual Basic)
Dim i As Integer
For i = 0 to 99
a( i ) = 0
Next
Насколько выгоден этот вид оптимизации? Вот результаты выполнения указанных фрагментов кода Visual Basic и аналогичных циклов, написанных на C++ и PHP:
Соотношение
Время выполнения
Время выполнения
Экономия быстродействия
Язык
кода до оптимизации оптимизированного времени
кода
C++
2,80 0,801 71%
3,5:1
PHP
5,01 4,65 7%
1:1
Visual Basic 6,84 0,280 96%
25:1
Использование массивов с минимальным
числом измерений
Использовать массивы, имеющие несколько измерений,
накладно. Если вы сможете структурировать данные так,
чтобы их можно было хранить в одномерном, а не двумер-
Перекрестная ссылка Об ис- пользовании целых чисел и чи- сел с плавающей запятой см.
главу 12.
Перекрестная ссылка О масси- вах см. раздел 12.8.

612
ЧАСТЬ V Усовершенствование кода ном или трехмерном массиве, вы скорее всего ускорите выполнение программы.
Допустим, у вас есть подобный код инициализации массива:
Пример стандартной инициализации двумерного массива (Java)
for ( row = 0; row < numRows; row++ ) {
for ( column = 0; column < numColumns; column++ ) {
matrix[ row ][ column ] = 0;
}
}
При инициализации массива из 50 строк и 20 столбцов этот код выполняется вдвое дольше, чем код инициализации аналогичного одномерного массива, сгенериро- ванный тем же компилятором Java. Вот как выглядел бы исправленный код:
Пример одномерного представления массива (Java)
for ( entry = 0; entry < numRows * numColumns; entry++ ) {
matrix[ entry ] = 0;
}
А вот результаты тестирования этого кода и похожего кода, написанного на не- скольких других языках:
Время выполнения
Время выполнения
оптимизированного Экономия Соотношение
Язык
кода до оптимизации кода
времени
быстродействия
C++
8,75 7,82 11%
1:1
C#
3,28 2,99 9%
1:1
Java
7,78 4,14 47%
2:1
PHP
6,24 4,10 34%
1,5:1
Python
3,31 2,23 32%
1,5:1
Visual Basic 9,43 3,22 66%
3:1
Примечание: временные показатели, указанные для Python и PHP, получены в резуль- тате более чем в 100 раз меньшего числа итераций, чем показатели, приведенные для других языков, поэтому их непосредственное сравнение недопустимо.
Результаты этого вида оптимизации прекрасны для Visual Basic и Java, хороши для
PHP и Python, но довольно заурядны для C++ и C#. Правда, время выполнения не- оптимизированного кода C# было лучшим, так что на это едва ли можно жаловаться.
Широкий разброс результатов лишь подтверждает недальновидность слепого сле- дования любым советам по оптимизации. Не испытав методику в конкретных об- стоятельствах, ни в чем нельзя быть уверенным.
Минимизация числа обращений к массивам
Кроме минимизации числа обращений к двумерным или трехмерным массивам часто выгодно минимизировать число обращений к массивам вообще. Подходя- щий кандидат для применения этой методики — цикл, в котором повторно ис-

ГЛАВА 26 Методики оптимизации кода
613
пользуется один и тот же элемент массива. Вот пример необязательного обраще- ния к массиву:
Пример необязательного обращения к массиву внутри цикла (C++)
for ( discountType = 0; discountType < typeCount; discountType++ ) {
for ( discountLevel = 0; discountLevel < levelCount; discountLevel++ ) {
rate[ discountLevel ] = rate[ discountLevel ] * discount[ discountType ];
}
}
При изменении индекса
discountLevel по мере выполнения внутреннего цикла обращение к массиву
discount[ discountType ] остается все тем же. Вы можете вы- нести его за пределы внутреннего цикла, и тогда у вас будет одно обращение к массиву на одну итерацию внешнего, а не внутреннего цикла. Вот оптимизиро- ванный код:
Пример вынесения обращения к массиву за пределы цикла (C++)
for ( discountType = 0; discountType < typeCount; discountType++ ) {
thisDiscount = discount[ discountType ];
for ( discountLevel = 0; discountLevel < levelCount; discountLevel++ ) {
rate[ discountLevel ] = rate[ discountLevel ] * thisDiscount;
}
}
Результаты:
Время выполнения
Время выполнения
Экономия
Язык
кода до оптимизации
оптимизированного кода
времени
C++
32,1 34,5
-7%
C#
18,3 17,0 7%
Visual Basic
23,2 18,4 20%
Примечание: тестирование выполнено для typeCount = 10 и levelCount = 100.
Как обычно, результаты зависят от конкретного компилятора.
Использование дополнительных индексов
Использование дополнительного индекса предполагает добавление данных, свя- занных с основным типом данных и повышающих эффективность обращений к нему. Связанные данные можно добавить к основному типу или хранить в парал- лельной структуре.
Индекс длины строки
Примером использования дополнительного индекса может служить одна из форм представления строк. В языке C строки заканчиваются нулевым байтом. Что каса- ется строк Visual Basic, то их длина хранится в начальном байте. Чтобы опреде- лить длину строки C, нужно начать с начала строки и продвигаться по ней, под- считывая байты, до достижения нулевого байта. Для определения длины строки
Visual Basic, нужно просто прочитать байт длины. Байт длины строки Visual Basic

614
ЧАСТЬ V Усовершенствование кода
— наглядный пример дополнения типа данных индексом, ускоряющим выполне- ние определенных операций, таких как вычисление длины строки.
Идею индексации длины можно приспособить к любому типу данных перемен- ной длины. Слежение за длиной структуры часто — более эффективный подход,
чем вычисление длины каждый раз, когда она требуется.
Независимая параллельная структура индексации
Иногда выгоднее работать с индексом типа данных, а не с самим типом данных.
Если элементы типа данных велики или их накладно перемещать (скажем, на диск),
сортировка и поиск по индексам будут выполняться быстрее, чем непосредственные операции над данными. Если каждый элемент данных велик, вы можете создать вспомогательную структуру, состоящую из ключевых значений и указателей на подробную информацию. Если различие размеров элемента структуры данных и элемента вспомогательной структуры достаточно велико, элемент-ключ можно хранить в памяти, а сами данные — на внешнем носителе. Все операции поиска и сортировки будут выполняться в памяти, а к диску можно будет обращаться толь- ко после определения точного расположения нужного вам элемента.
Кэширование
Кэширование — это такой способ хранения нескольких значений, при котором значения, используемые чаще всего, получить легче, чем значения, используемые реже. Так, если программа случайным образом читает записи с диска, метод мо- жет хранить в кэше записи, считываемые наиболее часто. Получив запрос запи- си, метод проверяет, имеется ли запись в кэше. Если да, запись возвращается не- посредственно из памяти, а не считывается с диска.
Кэширование можно применять и в других областях. В программе обработки шрифтов Microsoft Windows узким местом было получение ширины символа при его отображении на экране. Кэширование ширины символа, использованного последним, позволило примерно вдвое ускорить отображение.
Вы можете кэшировать и результаты ресурсоемких вычислений, особенно если их параметры просты. Пусть, например, вам нужно найти длину гипотенузы пря- моугольного треугольника по длинам двух катетов. Простая реализация этого метода была бы примерно такой:
Пример метода, напрашивающегося на кэширование (Java)
double Hypotenuse(
double sideA,
double sideB
) {
return Math.sqrt( ( sideA * sideA ) + ( sideB * sideB ) );
}
Если вы знаете, что те же значения скорее всего будут переданы в метод повтор- но, их можно кэшировать:

ГЛАВА 26 Методики оптимизации кода
615
Пример кэширования для предотвращения дорогих вычислений (Java)
private double cachedHypotenuse = 0;
private double cachedSideA = 0;
private double cachedSideB = 0;
public double Hypotenuse(
double sideA,
double sideB
) {
// Присутствуют ли параметры треугольника в кэше?
if ( ( sideA == cachedSideA ) && ( sideB == cachedSideB ) ) {
return cachedHypotenuse;
}
// Вычисление новой гипотенузы и ее кэширование.
cachedHypotenuse = Math.sqrt( ( sideA * sideA ) + ( sideB * sideB ) );
cachedSideA = sideA;
cachedSideB = sideB;
return cachedHypotenuse;
}
Вторая версия метода сложнее и объемнее первой, поэтому она должна обосно- вать свое право на жизнь быстродействием. Многие схемы кэширования предпо- лагают кэширование более одного элемента, и с ними связаны еще большие за- траты. Вот быстродействие двух версий метода:
Время выполнения
Время выполнения
оптимизированного Экономия Соотношение
Язык
кода до оптимизации кода
времени
быстродействия
C++
4,06 1,05 74%
4:1
Java
2,54 1,40 45%
2:1
Python
8,16 4,17 49%
2:1
Visual Basic 24,0 12,9 47%
2:1
Примечание: эти результаты предполагают, что на каждый промах кэша приходятся два попадания.
Польза кэширования зависит от относительной стоимости обращения к кэширо- ванному элементу, создания некэшированного элемента и сохранения нового элемента в кэше. Она также зависит от числа запросов кэшированной информа- ции. Иногда она может зависеть и от аппаратного кэширования. В целом чем до- роже генерирование нового элемента и чем чаще запрашивается та же самая ин- формация, тем выгоднее кэширование. Чем дешевле обращение к кэшированно- му элементу и сохранение новых элементов в кэше, тем выгоднее кэширование.
Как и другие методики оптимизации, кэширование усложняет код и часто оказы- вается источником ошибок.

616
ЧАСТЬ V Усовершенствование кода
26.4. Выражения
Многие задачи программирования требуют применения математических и логических выражений. Сложные выра- жения обычно дороги, и в этом разделе мы рассмотрим способы их удешевления.
Алгебраические тождества
Алгебраические тождества иногда позволяют заменить дорогие операции на бо- лее дешевые. Так, следующие выражения логически эквивалентны:
not a and not b not (a or b)
Выбрав второе выражение вместо первого, вы сэкономите одну операцию
not.
Устранение одной операции
not, вероятно, не приведет к заметным результатам,
однако в целом этот принцип очень полезен. Так, Джон Бентли пишет, что в од- ной программе проверялось условие
sqrt(x) < sqrt(y) (Bentley, 1982). Так как sqrt(x)
меньше
sqrt(y), только когда x меньше, чем y, исходную проверку можно заменить на
x < y. Если учесть дороговизну метода sqrt(), можно ожидать, что это приведет к огромной экономии. Так и есть:
Время выполнения
Время выполнения
оптимизированного Экономия Соотношение
Язык
кода до оптимизации кода
времени
быстродействия
C++
7,43 0,010 99,9%
750:1
Visual Basic 4,59 0,220 95%
20:1
Python
4,21 0,401 90%
10:1
Снижение стоимости операций
Как уже было сказано, снижение стоимости операций подразумевает замену до- рогой операции более дешевой. Вот некоторые возможные варианты:

замена умножения сложением;

замена возведения в степень умножением;

замена тригонометрических функций их эквивалентами;

замена типа
longlong на long или int (следите при этом за аспектами произво- дительности, связанными с применением целых чисел естественной и неес- тественной длины);

замена чисел с плавающей запятой числами с фиксированной точкой или целые числа;

замена чисел с плавающей запятой с удвоенной точностью числами с одинар- ной точностью;

замена умножения и деления целых чисел на два операциями сдвига.
Допустим, вам нужно вычислить многочлен. Если вы забыли, что такое многочле- ны, напомню, что это выражения вида
Ax2 + Bx + C. Буквы A, B и C — это коэффи-
Перекрестная ссылка О выраже- ниях см. раздел 19.1.

ГЛАВА 26 Методики оптимизации кода
617
циенты, а
x — переменная. Обычный код вычисления значения многочлена n-ной степени выглядит так:
Пример вычисления многочлена (Visual Basic)
value = coefficient( 0 )
For power = 1 To order value = value + coefficient( power ) * xˆpower
Next
Если вы подумаете о снижении стоимости операций, то поймете, что оператор возведения в степень — не самое эффективное решение в этом случае. Возведе- ние в степень можно заменить на умножение, выполняемое при каждой итера- ции цикла, что во многом похоже на снижение стоимости, выполненное нами ранее, когда умножение было заменено на сложение. Вот как выглядел бы код,
снижающий стоимость вычисления многочлена:
Пример снижения стоимости вычисления многочлена (Visual Basic)
value = coefficient( 0 )
powerOfX = x
For power = 1 to order value = value + coefficient( power ) * powerOfX
powerOfX = powerOfX * x
Next
Если вы имеете дело с многочленами второй или более высокой степени, выгода может быть очень приличной:
Время выполнения
Время выполнения
оптимизированного Экономия Соотношение
Язык
кода до оптимизации кода
времени
быстродействия
Python
3,24 2,60 20%
1:1
Visual Basic 6,26 0,160 97%
40:1
Если вы действительно серьезно отнесетесь к снижению стоимости операций, то позаботитесь и о двух умножениях чисел с плавающей запятой. Стоимость опе- раций, выполняемых в цикле, можно сделать еще меньшей, если постепенно воз- водить в нужную степень сразу несколько компонентов выражения, а не находить нужную степень при каждой итерации путем умножения:
Пример дальнейшего снижения стоимости вычисления многочлена (Visual Basic)
value = 0
For power = order to 1 Step -1
value = ( value + coefficient( power ) ) * x
Next value = value + coefficient( 0 )
В этой версии метода отсутствует переменная
powerOfX, а вместо двух умноже- ний при каждой итерации выполняется одно. Результаты таковы:

618
ЧАСТЬ V Усовершенствование кода
Экономия
Время
Время выполнения Время выполнения времени за
кода выполнения после первой
после второй
счет второй
Язык
до оптимизации
оптимизации
оптимизации
оптимизации
Python
3,24 2,60 2,53 3%
Visual Basic 6,26 0,16 0,31
-94%
Это хороший пример расхождения теории и практики. Код, имеющий снижен- ную стоимость, казалось бы, должен работать быстрее, но на деле это не так. Воз- можно, в Visual Basic снижение производительности объясняется декрементом счетчика цикла на
1 вместо инкремента, но чтобы говорить об этом с уверенно- стью, эту гипотезу нужно оценить.
Инициализация во время компиляции
Если вы вызываете метод, передавая ему в качестве единственного аргумента именованную константу или магическое число, попробуйте предварительно вы- числить нужное значение, присвоить его константе и избежать вызова метода. Это же справедливо для умножения, деления, сложения и других операций.
Однажды мне понадобилось вычислять значение двоичного логарифма целого числа, округленное до ближайшего целого числа. Система не предоставляла ме- тод вычисления двоичного логарифма, поэтому я написал собственный. Быстрый и легкий подход был основан на формуле:
log(x)base = log(x) / log(base)
Опираясь на это тождество, я написал такой метод:
Пример метода, вычисляющего двоичный логарифм
с использованием системных методов (C++)
unsigned int Log2( unsigned int x ) {
return (unsigned int) ( log( x ) / log( 2 ) );
}
Этот метод был очень медленным, а так как значение
log(2) измениться не может,
я заменил вызов метода
log(2) на действительное значение, равное 0.69314718:
Пример метода, вычисляющего двоичный логарифм
с использованием системного метода и константы (C++)
const double LOG2 = 0.69314718;
unsigned int Log2( unsigned int x ) {
return (unsigned int) ( log( x ) / LOG2 );
}
Вызов метода
log() довольно дорог — гораздо дороже преобразования типа или деления, и поэтому резонно предположить, что уменьшение числа вызовов мето- да
log() вдвое должно примерно в два раза ускорить выполнение метода. Вот ре- зультаты измерений:
Перекрестная ссылка О связы- вании переменных со значени- ями см. раздел 10.6.

ГЛАВА 26 Методики оптимизации кода
619
Время выполнения
Время выполнения
Экономия
Язык
кода до оптимизации
оптимизированного кода
времени
C++
9,66 5,97 38%
Java
17,0 12,3 28%
PHP
2,45 1,50 39%
Обоснованное предположение оказалось довольно близким к действительности.
Если учесть невысокую предсказуемость результатов, приводимых в этой главе,
точность моего прогноза в этом случае доказывает только то, что даже слепые белки иногда наталкиваются на орехи.
Недостатки системных методов
Системные методы дороги и часто обеспечивают избыточную точность. Напри- мер, большинство системных математических методов написаны с тем расчетом,
чтобы космонавт, отправившийся на Луну, прилунился с точностью ±2 фута. Если вам не нужна такая точность, нет смысла тратить на нее время.
В предыдущем примере метод
Log2() возвращал целое число, но использовал для его вычисления метод
log(), возвращающий число с плавающей запятой. Я не нуждался в такой точности, так что после своей первой попытки я написал ряд целочисленных проверок, которые прекрасно вычисляли целое значение двоичного логарифма:
Пример метода, возвращающего примерное значение двоичного логарифма (C++)
unsigned int Log2( unsigned int x ) {
if ( x < 2 ) return 0 ;
if ( x < 4 ) return 1 ;
if ( x < 8 ) return 2 ;
if ( x < 16 ) return 3 ;
if ( x < 32 ) return 4 ;
if ( x < 64 ) return 5 ;
if ( x < 128 ) return 6 ;
if ( x < 256 ) return 7 ;
if ( x < 512 ) return 8 ;
if ( x < 1024 ) return 9 ;
if ( x < 2147483648 ) return 30;
return 31 ;
}
Этот метод использует целочисленные операции, никогда не преобразовывает целые числа в числа с плавающей запятой и значительно превосходит по быст- родействию оба метода, работающих с числами с плавающей запятой:
Время выполнения
Время выполнения
оптимизированного Экономия Соотношение
Язык
кода до оптимизации кода
времени
быстродействия
C++
9,66 0,662 93%
15:1
Java
17,0 0,882 95%
20:1
PHP
2,45 3,45
-41%
2:3

620
ЧАСТЬ V Усовершенствование кода
Большинство так называемых «трансцендентных» функций разработано для наи- худшего случая, т. е. внутри себя они даже целочисленный аргумент преобразуют в число с плавающей запятой с удвоенной точностью. Обнаружив вызов такой функции в проблемном фрагменте кода, уделите ей самое пристальное внимание,
если, конечно, вам не нужна подобная точность.
Но вернемся к нашему методу. Еще один вариант его оптимизации основан на том факте, что деление на 2 аналогично операции сдвига вправо. Двоичный логарифм числа равен количеству операций деления на 2, которое можно выполнить над этим числом до получения нулевого значения. Вот как выглядит соответствующий код:
Пример альтернативного метода, определяющего примерное значение
двоичного логарифма с использованием оператора сдвига вправо (C++)
unsigned int Log2( unsigned int x ) {
unsigned int i = 0;
while ( ( x = ( x >> 1 ) ) != 0 ) {
i++;
}
return i ;
}
Читать этот код трудно, особенно программистам, не работавшим с C++. Слож- ное выражение в условии цикла
while — прекрасный пример того, что следует использовать только в крайних случаях.
Этот метод выполняется примерно на 350% дольше, чем более длинная предыду- щая версия (2,4 и 0,66 секунды соответственно), но он быстрее, чем первый опти- мизированный метод, и легко адаптируется к 32-, 64-разрядным и другим средам.
Этот пример ясно показывает, насколько полезно продолжать поиск после нахождения первого успешного вида оптимизации. Первый вид оптими- зации привел к приличному повышению быстродействия на 30–40%, но это не идет ни в какое сравнение с результатами второго и третьего видов опти- мизации.
Использование констант корректного типа
Используйте именованные константы и литералы, имеющие тот же тип, что и переменные, которым они присваиваются. Если константа и соответствующая ей переменная имеют разные типы, перед присвоением константы переменной ком- пилятор должен будет выполнить преобразование типа. Хорошие компиляторы преобразуют типы во время компиляции, чтобы не снижалась производительность в период выполнения программы.
Однако менее эффективные компиляторы или интерпретаторы генерируют код,
преобразующий типы в период выполнения. Чуть ниже указаны различия во вре- мени инициализации переменной с плавающей точкой
x и целочисленной пере- менной
i в двух случаях. В первом случае инициализация выглядит так:
x = 5
i = 3.14

1   ...   71   72   73   74   75   76   77   78   ...   104


написать администратору сайта