Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Итог_Пособие C++.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
2.03 Mб
Скачать

9.2.2 Методики оптимизации кода

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

Оптимизирующие компиляторы

Практически все современные компиляторы C++ являются оптимизирующими, то есть в них используются различные методы получения более оптимального кода при сохранении его функциональных возможностей. Компиляторы используют различные подходы к оптимизации кода − удаление ненужных вычислений, повторное использование результатов вычислений, хранение часто используемых переменных в регистрах процессора, использование встраивания функций и множество других способов.

Зачастую для получения достаточной производительности программы требуется лишь задать нужный ключ компилятора. Например, для компилятора g++ ключи -O1, -O2 и -O3 означают включение базовой, более сильной и ещё более сильной оптимизаций. Заметим, что при повышении уровня оптимизации время компиляции может существенно увеличиваться.

При отладке программ оптимизацию компилятора часто выключают, поскольку она затрудняет отладку (например, переменная может неожиданно оказаться в регистре, а не в памяти, и т.п.)

Рассмотрим несколько примеров. Пусть имеется функция, вычисляющая сумму кодов символов в строке:

// Пример 9.2.1 - пример оптимизации кода компилятором

int sum_of_chars(const char s[])

{

int sum = 0;

for (size_t i = 0; i < strlen(s); i++)

sum += s[i];

return sum;

}

Оценим её вычислительную сложность. На каждой итерации цикла производится вызов функции strlen для вычисления длины строки. Чтобы найти длину, функция strlen должна пробежаться по всей строке до завершающего символа с кодом нуль. Таким образом, количество операций в данной функции пропорционально квадрату длины строки.

Попробуем вызвать эту функцию со строкой-параметром длиной 100 000 символов. При использовании компилятора g++ c параметрами компиляции по умолчанию время работы на ноутбуке автора составило 2.6 секунды, а при указании ключа -O1 − всего 0.006 секунды. Разница более чем в 400 раз! Почему так получилось? Компилятор заметил, что на каждой итерации цикла функция strlen возвращает одно и то же число, поскольку строка в теле цикла не меняется. Поэтому компилятор исправил код так, чтобы функция strlen вызывалась лишь один раз перед циклом, и вычислительная сложность с квадратичной снизилась до линейной.

Рассмотрим похожий пример. Дана функция, которая заменяет в строке точки на запятые:

// Пример 9.2.2 - ещё один пример оптимизации кода компилятором

void change_dots_to_commas(char s[])

{

for (size_t i = 0; i < strlen(s); i++)

if (s[i] == '.')

s[i] = ',';

}

Отличие от предыдущего примера в том, что здесь строка может меняться внутри цикла, хотя длина её измениться по-прежнему не может. При включении оптимизации первого уровня (с ключом -O1) компилятор g++ не смог об этом догадаться. Более того, программа стала работать даже медленней, чем вообще без оптимизации (как это ни странно). Однако, использование ключа -O2 решило проблему − компилятор сообразил, что нигде в теле цикла символам строки не присваивается нулевой символ, потому её длина измениться не может.

Интересно, что компилятор Visual C++ 2013 даже при использовании ключа /O2 не смог оптимизировать пример 9.2.2. Это подтверждает мысль, что слишком сильно надеяться на компилятор не стоит − далеко не всегда он сможет поправить неэффективный код. Приведём версию функции change_dots_to_commas, которая будет эффективно работать при любом компиляторе и любых настройках оптимизации:

// Пример 9.2.3 - более эффективная реализация примера 9.2.2

void change_dots_to_commas(char s[])

{

size_t n = strlen(s);

for (size_t i = 0; i < n; i++)

if (s[i] == '.')

s[i] = ',';

}

В принципе, можно попытаться улучшить и эту версию, вообще избавившись от вызова strlen и даже от обращения к массиву по индексу:

// Пример 9.2.4 - ещё один вариант функции

void change_dots_to_commas(char s[]) {

for (char *c = s; *c != 0; c++)

if (*c == '.')

*c = ',';

}

Однако, какого-либо заметного ускорения данный вариант по сравнению с предыдущим не даёт, а понятность кода стала, возможно, чуть хуже.

В заключение данного подраздела стоит упомянуть ещё об использовании спецификатора inline в языке c++. Наличие спецификатора inline перед именем функции указывает компилятору сделать, по возможности, данную функцию встроенной. Это означает, что всякий раз, когда в тексте программы встречается вызов функции, компилятор будет вместо этого вставлять в текущее место полный код этой функции. Это позволяет не тратить процессорное время на помещение аргументов в стек, выполнение команды вызова, очистку стека при выходе из функции.

Необходимость использовать спецификатор inline у некоторых программистов вызывает сомнения, так как при задании максимального уровня оптимизации компилятор может сам решать, делать функцию встроенной, или нет. Однако, в некоторых случаях явное указание данного спецификатора оправдано. Например, как ни странно, он помогает для ускорения работы рекурсивных функций. Конечно, сделать рекурсивную функцию целиком встроенной компилятор не может, однако всё же способен добиться заметного ускорения.

Приведём пример из практического опыта автора. На одном турнире по программированию предлагалось решить задачу на перебор вариантов. Основную часть решения составляла рекурсивная функция, выполняющая перебор с возвратом (backtracking). При указании слова inline время работы программы сократилось примерно на 30% (в обоих случаях использовался ключ компиляции -O3).

Использование более быстрых операций

Одним из методов оптимизации кода является применение более быстрых операций при вычислениях. Для этого необходимо иметь представление о скорости выполнения различных операций. Приведём таблицу с примерным относительным временем выполнения некоторых операций и стандартных функций C++ для целых и вещественных типов данных.

Некоторые операции и стандартные функции

Относительное время выполнения (приближённо)

=, +, -, *, &, |, <<, >>, ~

обращение к элементу массива по индексу, вызов функции с одним параметром

1

/ (для типа double)

3

/, % (для типа int)

4

sqrt, sin, cos, tan, exp, log

20-50

Данные результаты были замерены на компьютере с процессором Intel Core i5, использовался компилятор g++ 4.9.2. Разумеется, для других платформ результаты могут несколько отличаться.

Суть данного способа оптимизации состоит в замене "тяжёлых" операций на более лёгкие. Рассмотрим пример. Допустим, нужно вычислить выражение 2x, где x − целое число от 0 до 30. Начинающий программист может написать такой код:

int result = (int) pow(2, x);

Как его ускорить? Заметим, что возведение двойки в целую степень эквивалентно побитовому сдвигу влево единицы на x разрядов. Тогда наш код запишется так:

int result = 1 << x;

Такой вариант работает в десятки раз быстрее предыдущего.

Рассмотрим ещё один пример. Дана функция, которая делит все элементы вещественного вектора x на заданное число d:

void div_all(std::vector<double> &x, double d)

{

for (double &e: x)

e /= d;

}

Как её можно ускорить? Из таблицы выше мы знаем, что деление выполняется заметно медленнее умножения. Заменим деление на d умножением на число 1/d, которое вычислим один раз перед циклом:

void div_all(std::vector<double> &x, double d)

{

double rev_d = 1.0 / d;

for (double &e: x)

e *= rev_d;

}

Результатом стало ускорение работы функции почти в полтора раза.

Модификация выражений, устранение лишних вычислений

Помимо замены тяжёлых операций более лёгкими, зачастую можно уменьшить количество операций путём замены выражения эквивалентным. Например, многочлен ax2+bx+c на C++ можно записать как a*x*x+b*x+c, а можно в виде (a*x+b)*x+c. Во втором выражении операций на одну меньше.

Рассмотрим более сложный пример. Дано множество точек на плоскости. Требуется отсортировать их по возрастанию расстояния от начала координат. Первоначальный вариант кода выглядит так:

// Пример 9.2.5 - сортировка точек

struct Point

{

double x, y;

};

std::vector<Point> p;

//...

std::sort(p.begin(), p.end(), [](Point &p1, Point &p2)

{

return sqrt(p1.x*p1.x+p1.y*p1.y) < sqrt(p2.x*p2.x+p2.y*p2.y);

});

Однако, этот код работает довольно медленно. Чтобы его ускорить, заметим, что вместо сортировки точек по расстоянию от начала координат с тем же успехом можно сортировать их по квадрату расстояния − результат от этого не изменится. Это позволяет убрать вызов медленной функции sqrt:

// Пример 9.2.6 - сортировка точек после оптимизации

struct Point

{

double x, y;

};

std::vector<Point> p;

//...

std::sort(p.begin(), p.end(), [](Point &p1, Point &p2) {

return p1.x*p1.x+p1.y*p1.y < p2.x*p2.x+p2.y*p2.y;

});

Такая оптимизация ускорила код почти в 4 раза.

Оптимизация логических выражений

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

В C++ вычисление булевских выражений производится по так называемой короткой схеме: выражение вычисляется слева направо, при этом вычисление останавливается, как только результат можно однозначно определить. Например, пусть имеется такая строчка:

if (x == 0 && y == 0) {...}

При вычислении этого выражения вначале проверяется условие x==0. Если оно неверно, то уже ясно, что результат всего выражения будет false. Поэтому вторая часть условия вычисляться не будет. Отсюда получается следующий способ оптимизации: при определении порядка условий учитывать вероятность их возникновения (чтобы вычисление всего выражения как можно чаще заканчивалось досрочно), а также вычислительную сложность проверки условий (более "лёгкие" условия ставить раньше).

Рассмотрим пример. Дан вектор, заполненный случайными целыми числами. Требуется определить, сколько чисел в нём одновременно делится на 3 и на 83. Который из вариантов условного оператора будет работать быстрей?

if (x[i] % 83 == 0 && x[i] % 3 == 0)

или

if (x[i] % 3 == 0 && x[i] % 83 == 0) ?

Если числа действительно случайные, то, очевидно, первый вариант лучше. Ведь гораздо чаще будут попадаться числа, которые не делятся на 83, чем числа, которые не делятся на 3. Замер времени показал, что программа с первым вариантом работает быстрее примерно на 20%.

Примечание. Ещё более быстрый вариант выглядит так:

if (x[i] % 249 == 0)

В некоторых случаях можно вообще убрать лишние условия − например, с использованием так называемого барьерного (сигнального) элемента. Рассмотрим пример. Дан вектор a, требуется удвоить в нём все элементы от начала и до первого вхождения числа 0 (а если такого нет, то просто все элементы). Функция выглядит так:

void double_till_first_zero(std::vector<int> &v)

{

for (size_t i = 0; i < v.size() && v[i] != 0; i++)

v[i] *= 2;

}

На каждой итерации цикла здесь проверяются два условия − i<v.size() и v[i]==0. Можно ли убрать одно из них? Заранее установим размер вектора на единицу больше, чем число элементов, и запишем после последнего элемента число 0. При этом исчезает необходимость проверять выход за границу вектора, и условие i<v.size() можно убрать. В этом примере сколько-нибудь заметного прироста производительности нет, но всё же иногда метод барьерного элемента может оказаться полезен.

Интересным способом оптимизации кода является полное устранение условного оператора. Рассмотрим пример. Требуется написать функцию, которая возвращает количество нечётных элементов в векторе. Первоначальный вариант выглядит так (для проверки чётности здесь сразу используется эффективный способ − проверка значения младшего бита):

// Пример 9.2.6 - количество нечётных

int count_odd(const std::vector<int> &v)

{

int count = 0;

for (int x : v)

if (x & 1)

count++;

return count;

}

Попробуем вообще убрать оператор if. Для этого заметим, что искомым ответом будет просто сумма младших битов всех чисел: если число чётное, к ответу добавится 0, нечётное − 1. Получаем такое решение:

// Пример 9.2.7 - количество нечётных после оптимизации

int count_odd(const std::vector<int> &v)

{

int count = 0;

for (int x : v)

count += x & 1;

return count;

}

Интересно, что для компилятора Visual C++ 2013 мы добились ускорения примерно на 18%, в то время как при использовании g++ 4.9.2 оба варианта работают примерно одинаково быстро. То есть компилятор g++ сам смог оптимизировать первый вариант кода и получил, вероятно, что-то похожее на второй.

Оптимизация работы с памятью

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

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

Из вышенаписанного можно сделать вывод: программа, которая обрабатывает данные в памяти последовательно, работает заметно эффективнее, чем программа, которая "прыгает" по памяти, даже если они выполняют одинаковое число операций. Рассмотрим простой пример. Имеется достаточно большой двухмерный массив размера n x m (например, n=m=1000). Требуется вычислить сумму элементов массива. Который из двух вариантов кода работает лучше?

// Пример 9.2.8 - особенности работы кэша

// сумма элементов матрицы - вариант 1

int sum = 0;

for (int i = 0; i < n; i++)

for (int j = 0; j < m; j++)

sum += a[i][j];

// сумма элементов матрицы - вариант 2

int sum = 0;

for (int j = 0; j < m; j++)

for (int i = 0; i < n; i++)

sum += a[i][j];

Первый вариант кода на машине автора работает в 4.5 раза быстрее. Причина в том, что в памяти элементы матрицы хранятся по строкам − a[0][0], в следующей ячейке − a[0][1], и так далее. Первый вариант кода обходит матрицу по строкам, при этом достигается большое число попаданий в кэш. Во второй варианте матрица обходится по столбцам, при этом попаданий в кэш оказывается значительно меньше.

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

Эффективное использование кэша может являться причиной, почему некоторые алгоритмы работают быстрее других несмотря на то, что они выполняют примерно одинаковое число операций − например, быстрая сортировка Хоара (quicksort) работает заметно быстрее алгоритма сортировки пирамидой (heapsort).

Предсказание переходов

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

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

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

Для примера рассмотрим две реализации сортировки массива методом пузырька (пример взят из статьи https://habrahabr.ru/post/73726).

// Пример 9.2.9 - две сортировки методом пузырька

// Вариант 1

for (int i = 0; i < N; i++)

for (int j = 0; j < N-i-1; j++)

if (a[j] > a[j+1])

std::swap(a[j], a[j+1]);

}

// Вариант 2

void bubble2()

{

for (int i = 0; i < N-1; i++)

for (int j = i; j >= 0; j--)

if (a[j] > a[j+1])

std::swap(a[j], a[j+1]);

}

Второй код почти не отличается от первого, но работает примерно в три раза быстрее. Чтобы разобраться, почему так, посмотрим на условие a[j]>a[j+1]. В первом случае при небольших i внутренний цикл по j пробегает почти до конца массива, при этом условие a[j]>a[j+1] выполняется почти случайно. При увеличении i элементы немного упорядочиваются, но всё равно остаётся большая доля случайности.

Во втором случае внутренний цикл бежит по уже отсортированной части массива, в который вставляется новый элемент a[i+1] (фактически, это сортировка вставками). При этом, пока элемент не встал на свою позицию, условие a[j]>a[j+1] будет оказываться всё время истинным, а затем до конца цикла всё время ложным. После нескольких итераций цикла модуль предсказания переходов начинает правильно угадывать результат ветвления, что и приводит к трёхкратной разнице в скорости.

Ускорение ввода-вывода

Если в программе выполняется ввод/вывод значительных объёмов данных в файлы, то операции ввода/вывода легко могут стать узким местом. В связи с этим необходимо хорошо знать классы и функции для ввода/вывода в стандартной библиотеке C++, в том числе представлять себе их сравнительную эффективность.

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

Рассмотрим следующую задачу. Дан текстовый файл input.txt. В первой его строке содержится натуральное число N, за ним идут N неотрицательных целых чисел. Требуется прочитать всё содержимое файла в вектор. Для эксперимента нами был создан сравнительно большой файл размером 64 мегабайта, содержащий 10 миллионов чисел.

Ниже приводится несколько вариантов решения данной задачи. В первом варианте для работы с классом используется стандартный класс std::ifstream из заголовочного файла fstream.

Во втором варианте работа с файлом проводится в стиле языка C − используются функции из заголовочного файла stdio.h. Для чтения очередного числа из файла используется функция fscanf.

В третьем варианте решения используется функция fread, которая позволяет за один раз прочитать всё содержимое файла в память. Затем полученная строка разбивается на лексемы с помощью функции strtok, а каждая лексема переводится в число стандартной функцией atoi.

Четвёртый вариант является модификацией третьего − вместо функция atoi написана собственная функция str_to_int для перевода строки в число.

Поскольку все функции писались лишь для проведения эксперимента, в них отсутствуют проверки существования файла и правильности его содержимого.

// Пример 9.2.10 - разные реализации файлового ввода

// Вариант 1 - используем std::ifstream

std::vector<int> read1()

{

std::ifstream fin("input.txt");

int n; fin >> n;

std::vector<int> a(n);

for (int i = 0; i < n; i++)

fin >> a[i];

return a;

}

// Вариант 2 - используем fscanf

std::vector<int> read2()

{

FILE *f = fopen("input.txt", "r");

int n; fscanf(f, "%d", &n);

std::vector<int> a(n);

for (int i = 0; i < n; i++)

fscanf(f, "%d", &a[i]);

fclose(f);

return a;

}

// Вариант 3 - используем fread + strtok + atoi

std::vector<int> read3()

{

FILE *f = fopen("input.txt", "rb");

// получим размер файла

fseek(f, 0, SEEK_END);

int size = ftell(f);

fseek(f, 0, SEEK_SET);

// прочитаем данные

char *s = new char[size + 1];

fread(s, 1, size, f);

char *p = strtok(s, " \r\n");

int n = atoi(p);

std::vector<int> a(n);

for (int i = 0; i < n; i++)

{

p = strtok(NULL, " \r\n");

a[i] = atoi(p);

}

fclose(f);

return a;

}

// Вариант 4 - своя функция для замены atoi в функции read3

inline int str_to_int(char s[])

{

int res = 0;

for (char *p = s; *p != 0; p++)

res = res * 10 + *p - '0';

return res;

}

В следующей таблице приведено время работы программы.

Способ ввода

Результат, g++ 4.9.2 (mingw)

Результат, Visual C++ 2013

std::ifstream

6.5 сек.

6.7 сек.

fscanf

2.5 сек.

2.2 сек.

fread + strtok + atoi

1.1 сек.

1.2 сек.

fread + strtok + своя функция перевода строки в число

0.08 сек.

0.08 сек.

Результаты получились довольно интересными. Во-первых, видно, что объектно-ориентированный ввод в стиле C++ заметно проигрывает по скорости вводу в "старом" стиле языка C. Можно также отметить, что в некоторых компиляторах, как это ни странно, скорость ввода при использовании директивы #include <stdio.h> может оказаться выше, чем при использовании #include <cstdio>.

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

И самый интересный результат: оказалось, что очень сильно влияет на производительность способ преобразования прочитанных данных в тип int. Замена библиотечной функции atoi на собственную функцию str_to_int повысила скорость работы более чем на порядок. Вероятно, это связано с тем, что в нашей функции str_to_int не делается никаких проверок − предполагается, что входная строка содержит корректную запись числа.

Заметим, что разница между самым быстрым и самым медленным способом ввода составила более 80 раз!

Кэширование вычислений. Преподсчёт

Идея кэширования вычислений заключается в следующем. Пусть нам потребовалось вычислить какое-то значение, и есть вероятность, что это нам потребуется сделать и в будущем. Тогда сохраним данное значение в памяти, и в следующий раз его не придётся считать повторно. Для примера решим такую задачу: нужно написать функцию, которая находит сумму цифр целого неотрицательного числа, не превосходящего 106.

В простейшем варианте функция будет выглядеть так:

// Пример 9.2.11 - сумма цифр

int sum_digits(int n)

{

assert(n>=0 && n<=1000000);

int sum = 0;

while (n > 0)

{

sum += n % 10; n /= 10;

}

return sum;

}

Однако, если функцию вызвать несколько раз с одним и тем же аргументом, она будет каждый раз вычислять значение заново. Чтобы этого избежать, добавим в функцию статический массив res:

// Пример 9.2.12 - сумма цифр с кэшированием вычислений

int sum_digits(int n)

{

assert(n>=0 && n<=1000000);

static int res[1000001];

if (res[n] == 0)

{

int sum = 0;

while (n > 0)

{

sum += n % 10; n /= 10;

}

res[n] = sum;

}

return res[n];

}

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

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

Например, для нашей задачи с суммой цифр можно было бы при запуске программы сформировать всю таблицу res целиком − тогда любой последующий запрос будет обработан максимально быстро. Однако, при этом будет наблюдаться некоторая задержка при запуске программы. Можно использовать компромиссный вариант − например, заполнить массив для первых 1000 чисел. Тогда, чтобы получить ответ для числа n до 106, нужно разбить его на две половинки, взять в массиве ответы для этих половинок и сложить.

Оптимизация циклов

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

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

Существует целый ряд других подходов к оптимизации циклов − перестановка вложенных циклов местами, объединение, размыкание, развёртывание, расщепление циклов и др. Однако, современные компиляторы в основном умеют самостоятельно применять многие способы оптимизации циклов, поэтому подробно останавливаться на них мы не будем.

Использование языка более низкого уровня

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

Для примера рассмотрим следующую задачу: требуется написать максимально быстро работающую функцию, возвращающую количество нулей, которыми оканчивается двоичное представление числа. Существуют разные подходы, как это сделать. Для процессоров x86 один из наиболее эффективных способов состоит в использовании специальной инструкции bsfl. Код с ассемблерной вставкой для компилятора g++ выглядит так:

inline unsigned int bsf(unsigned int x)

{

__asm("bsfl %0, %0" : "+r"(x));

return x;

}

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]