44
Углубленное программирование на языке C++ Алексей Петров

C++ осень 2013 лекция 2

Embed Size (px)

Citation preview

Page 1: C++ осень 2013 лекция 2

Углубленное программирование

на языке C++

Алексей Петров

Page 2: C++ осень 2013 лекция 2

2

1. Многомерные массивы. 2. Выравнивание и упаковка

переменных составных типов. 3. Системные аспекты выделения и

освобождения памяти. 4. Взаимодействие с операционной

системой. 5. Оптимизация работы с кэш-памятью

ЦП ЭВМ. 6. Постановка индивидуальных задач к

практикуму №2.

Лекция №2. Дополнительные вопросы организации и использования памяти в программах на языке C

Page 3: C++ осень 2013 лекция 2

Многомерные массивы

Двумерный массив — объект данных T a[N][M], который:

содержит N последовательно расположенных в памяти строк по M элементов типа T в каждой;

в общем и целом инициализируется аналогично одномерным массивам;

по характеристикам выравнивания идентичен объекту T a[N * M], что сводит его двумерный характер к удобному умозрительному приему, упрощающему обсуждение и визуализацию порядка размещения данных.

Массивы размерности больше двух считаются многомерными, при этом (N + 1)-мерные массивы индуктивно определяются как линеаризованные массивы N-мерных массивов, для которых справедливо все сказанное об одно- и двумерных массивах.

3

Page 4: C++ осень 2013 лекция 2

Многомерные массивы: пример

4

// определение двумерных массивов

int a[2][3] = {

{0, 1}, // частичная инициализация строки

{2, 3, 4}}; // полная инициализация строки

int b[2][3] = {0, 1, 2, 3, 4};

// результаты:

// a: {0, 1, 0, 2, 3, 4}; b: {0, 1, 2, 3, 4, 0}

// определение массивов размерности больше 2

double d[3][5][10];

int32_t k[5][4][3][2];

Page 5: C++ осень 2013 лекция 2

Эффективный обход двумерных массивов

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

для массивов, объем которых превышает размер (выделенной процессу) кэш-памяти данных самого верхнего уровня (напр., L2d), время инициализации по строкам приблизительно втрое меньше времени инициализации по столбцам вне зависимости от того, ведется ли запись в кэш-память или в оперативную память в обход нее (У. Дреппер, 2007).

Дальнейшая оптимизация может быть связана с анализом и переработкой решаемой задачи в целях снижения частоты кэш-промахов или использования векторных инструкций процессора (SIMD — Single Instruction, Multiple Data).

5

Page 6: C++ осень 2013 лекция 2

Задача об умножении матриц (1 / 2)

Классическим примером задачи, требующей неэффективного, с точки зрения архитектуры ЭВМ, обхода массива по столбцам, является задача об умножении матриц: 𝐴𝐵 𝑖𝑗 = 𝑎𝑖𝑘𝑏𝑘𝑗

𝑁−1𝑘=0

— имеющая следующее очевидное решение:

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

for(j = 0; j < N; j++)

for(k = 0; k < N; k++)

res[i][j] +=

mul1[i][k] * mul2[k][j];

6

Page 7: C++ осень 2013 лекция 2

Задача об умножении матриц (2 / 2)

Предварительное транспонирование второй матрицы повышает эффективность решения в 4 раза (с 16,77 млн. до 3,92 млн. циклов ЦП Intel Core 2 с внутренней частотой 2666МГц).

Математически: 𝐴𝐵 𝑖𝑗 = 𝑎𝑖𝑘𝑏𝑗𝑘𝑇𝑁−1

𝑘=0 .

На языке C:

double tmp[N][N];

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

for(j = 0; j < N; j++)

tmp[i][j] = mul2[j][i];

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

for(j = 0; j < N; j++)

for(k = 0; k < N; k++)

res[i][j] += mul1[i][k] * tmp[j][k];

7

Page 8: C++ осень 2013 лекция 2

Двумерные массивы и векторы векторов

Двумерный массив следует отличать от вектора векторов, работа с которым:

предполагает двухступенчатую процедуру создания и удаления;

гарантирует смежность хранения данных только в пределах одной строки (аналогичная гарантия предоставляется и в отношении указателей на строки);

ведет к большей фрагментации памяти, но повышает вероятность успешного выделения в памяти непрерывных фрагментов (требование памяти объема ~𝑁2 заменяется требованием памяти объема ~𝑁).

Многомерные массивы и векторы векторов (векторов…) являются различными структурами данных с разной дисциплиной использования.

8

Page 9: C++ осень 2013 лекция 2

Двумерные массивы и векторы векторов: пример

9

// создание вектора векторов

int **v = (int**)malloc(N * sizeof(int*));

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

// NB: в каждой строке значение M может быть разным

v[i] = (int*)malloc(M * sizeof(int));

// ...

// удаление вектора векторов

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

free(v[i]);

free(v);

Page 10: C++ осень 2013 лекция 2

Многомерные массивы и указатели

Для многомерных массивов справедлив ряд тождеств, отражающих эквивалентность соответствующих выражений языка C. Так, для двумерного массива T a[N][M] справедливо:

a == &a[0]; a + i == &a[i];

*a = a[0] == &a[0][0];

**a == *&a[0][0] == a[0][0];

a[i][j] == *(*(a + i) + j).

Использование операции разыменования * не имеет каких-либо преимуществ перед доступом по индексу, и наоборот. Трансляция и первой, и второй формы записи в объектный код приводит в целом к одинаковым результатам.

10

Page 11: C++ осень 2013 лекция 2

Многомерные массивы и указатели: пример

11

// указатели на массивы и массивы указателей

int k[3][5];

int (*pk)[5]; // указатель на массив int[5]

int *p[5]; // массив указателей (int*)[5]

// примеры использования (все — допустимы)

pk = k; // аналогично: pk = &k[0];

pk[0][0] = 1; // аналогично: k[0][0] = 1;

*pk[0] = 2; // аналогично: k[0][0] = 2;

**pk = 3; // аналогично: k[0][0] = 3;

Page 12: C++ осень 2013 лекция 2

Совместимость указателей

Общеизвестно, что для любого конкретного типа T, не тождественного ему типа Y и указателей T *pt и Y *py недопустимо присваивание:

pt = py; // T — не void

Представленный пример является частным случаем более общего правила совместимости указателей, расширением которого является запрет на присваивание значения указателя на константу «обычному» указателю.

12

Page 13: C++ осень 2013 лекция 2

Совместимость указателей: пример

13

// определения

double *pd, **ppd;

double (*pda)[2];

double dbl32[3][2];

double dbl23[2][3];

// допустимые примеры использования

pd = &dbl32[0][0]; // double* -> double*

pd = dbl32[1]; // double[] -> double*

pda = dbl32; // double(*)[2] -> double(*)[2]

ppd = &pd; // double** -> double**

// недопустимые примеры использования

pd = dbl32; // double[][] -> double*

pda = dbl23; // double(*)[3] -> double(*)[2]

Page 14: C++ осень 2013 лекция 2

Указатели на константы и константные указатели

Различное положение квалификатора const в определении указателя позволяет вводить в исходном коде программ четыре разновидности указателей:

«обычный» указатель (вариант 1) — изменяемый указатель на изменяемую через него область памяти;

указатель на константу (вариант 2) — изменяемый указатель на неизменяемую через него область памяти;

константный указатель (вариант 3) — неизменяемый указатель на изменяемую через него область памяти;

константный указатель на константу (вариант 4) — неизменяемый указатель на неизменяемую через него область памяти.

14

Page 15: C++ осень 2013 лекция 2

Указатели на константы и константные указатели: пример

15

// пример 1: определения

int *p1; // обычный указатель

const int *pc2; // указатель на константу

int *const cp3; // константный указатель

const int *const cpc4; // конст. указатель на константу

// пример 2: совместимость T*, const T* и const T**

int *pi;

const int *pci;

const int **ppci;

pci = pi; // допустимо: int* -> const int*

pi = pci; // недопустимо: const int* -> int*

ppci = &pi; // недопустимо: int** -> const int**

Page 16: C++ осень 2013 лекция 2

Указатели и квалификатор restrict

Использование ключевого слова restrict допустимо строго в отношении указателей и…

расширяет возможности компилятора по оптимизации некоторых видов кода путем поиска сокращенных методов вычислений;

означает, что указатель — единственное (других нет) исходное (первоначальное) средство доступа к соответствующему объекту.

В отсутствие квалификатора restrict компилятор вынужден прибегать к «пессимистичной» стратегии оптимизации.

Квалификатор restrict широко применяется в отношении формальных параметров функций стандартной библиотеки языка C. Например:

void *memcpy(void *restrict s1, const void *restrict s2, size_t n);

int sscanf(const char *restrict s, const char *restrict format, ... );

16

Page 17: C++ осень 2013 лекция 2

Указатели и квалификатор restrict: пример

17

// restrict: оптимистичная стратегия

int *restrict a = (int*)malloc(N * sizeof(int));

a[k] *= 2; // (1)

// здесь следует иной код без участия a[k]

a[k] *= 3; // (2)

// строки (1) и (2) вкупе эквивалентны: a[k] *= 6;

// пессимистичная стратегия

double d[N]; // d — не единственное средство доступа

double *p = d; // p — не исходное средство доступа (см. d)

d[k] *= 2; // (3)

// здесь следует иной код без участия d[k]

d[k] *= 3; // (4)

// строки (3) и (4) не могут быть объединены ввиду существ. p

Page 18: C++ осень 2013 лекция 2

Многомерные массивы и функции

В общем случае для передачи функции двумерного массива T a[N][M] используется формальный параметр T **pa. При этом информация о размерности массива закреплена в типе (T**), а размеры передаются как дополнительные параметры.

Существование в языке объектов вида T (*pt) [M] открывает возможность встраивания информации о длине строк массива в тип данных и определения функций с прототипами вида:

void foo(T a[][M], size_t N);

void foo(T (*a)[M], size_t N); // комплект [] трактуется как указатель

Для многомерных массивов аналогично имеем:

void bar(T b[][SZ2][SZ3][SZ4], size_t sz1);

void bar(T (*b)[SZ2][SZ3][SZ4], size_t sz1);

18

Page 19: C++ осень 2013 лекция 2

Массивы переменной длины (1 / 2)

Массивы переменной длины — введенное в стандарте C99 языковое средство динамического (а не традиционно статического) распределения локальной памяти функций. Размеры таких массивов по каждому измерению остаются неизвестными до момента исполнения кода функции:

концепция массивов переменной длины (VLA, variable-length array) — ответ разработчиков C99 на требование научного сообщества обеспечить переносимость на язык C наработанной десятилетиями базы на языке FORTRAN, более гибком в части работы функций с массивами.

Размер массива переменной длины:

определяется переменными (отсюда название);

остается постоянным до уничтожения объекта.

19

Page 20: C++ осень 2013 лекция 2

Массивы переменной длины (2 / 2)

С учетом ограничений C99 массивы переменной длины:

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

определяются внутри блоков и прототипов функций;

не допускают инициализации при создании.

Идентификатор массива переменной длины, как и традиционного массива, является указателем.

Использование массива переменной длины как формального параметра функции означает передачу данных по указателю.

20

Page 21: C++ осень 2013 лекция 2

Массивы переменной длины: пример

21

// прототип функции

int foo(size_t rows, size_t cols, double a[rows][cols]);

int foo(size_t, size_t, double a[*][*]); // имена опущены

// определение функции

int foo(size_t rows, size_t cols, double a[rows][cols])

{ /* ... */ }

int bar()

{

int n = 3, m = 4;

int var[n][m]; // массив как локальная переменная

// ...

}

Page 22: C++ осень 2013 лекция 2

Выравнивание переменных составных типов

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

Другими словами, даже при «подгоне» элементов структуры под линию L1d размещенный в ОЗУ объект может не обладать требуемым выравниванием.

Принудительное выравнивание статически и динамически размещаемых структур выполняется аналогично выравниванию скалярных переменных и начальных элементов массивов:

GCC-атрибут aligned в определении типа или объекта данных;

функция posix_memalign.

22

Page 23: C++ осень 2013 лекция 2

Упаковка переменных составных типов (1 / 2)

Для структур данных актуален также не характерный для массивов и скаляров вопрос упаковки данных, обусловленный наличием у элементов структур индивидуальных характеристик выравнивания.

Проблема упаковки структур заключается в том, что смежные (перечисленные подряд) элементы часто физически не «примыкают» друг к другу в памяти.

23

Page 24: C++ осень 2013 лекция 2

Упаковка переменных составных типов (2 / 2)

Например (для x86): typedef struct {

int id; // 4 байта

char name[15]; // 15 байт

double amount; // 8 байт

_Bool active; // 1 байт

} account; // 28 байт (не 32 байта!)

Наличие лакун, аналогичных выявленным 4-байтовым (13%) потерям в структуре account, вызывается совокупностью факторов:

архитектура процессора (напр., x86 или x86-64);

оптимизирующие действия компилятора;

выбранный программистом порядок следования элементов.

24

Page 25: C++ осень 2013 лекция 2

Утилита pahole

Самый доступный способ изучить физическое размещение элементов структуры в оперативной памяти и сопоставить их с загрузкой линий кэш-памяти L1d — утилита pahole, входящая в пакет dwarves.

Использование утилиты pahole позволяет:

проанализировать размещение элементов структур относительно линий кэш-памяти данных;

получить варианты реорганизации структуры (параметр --reorganize).

25

Page 26: C++ осень 2013 лекция 2

Реорганизация структур данных: рекомендации

Реорганизация структур для повышения эффективности использования кэш-памяти должна идти по двум направлениям:

декомпозиция тяжеловесных («божественных») структур на более мелкие, узкоспециализированные структуры, которые при решении конкретной задачи используются полностью либо не используются вообще;

устранение в структурах лакун, обусловленных характеристиками выравнивания типов их элементов (см. ранее).

При прочих равных условиях крайне желательно:

переносить наиболее востребованные элементы структуры к ее началу (при загрузке в кэш-память такие элементы структуры могут становиться «критическими словами», доступ к которым должен быть самым быстрым);

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

26

Page 27: C++ осень 2013 лекция 2

Реорганизация структур данных: рекомендации: пример

27

typedef struct { // вар. 1: 28/32 байт (x86: 13% потерь)

int id; // 4 байта

char name[15]; // 15 байт

/* лакуна — 1 байт выравнивания */

double amount; // 8 байт

_Bool active; // 1 байт

/* лакуна — 3 байта выравнивания */

} account_1; // 32 байта

typedef struct { // вар. 2: 28/28 байт (x86: 0% потерь)

int id; // 4 байта

char name[15]; // 15 байт

_Bool active; // 1 байт

double amount; // 8 байт

} account_2; // 28 байт

Page 28: C++ осень 2013 лекция 2

Реорганизация структур данных: недостатки и альтернатива решения

Недостатки реорганизации:

снижение удобства чтения и сопровождения исходного кода;

риск размещения совместно используемых элементов (напр., длины вектора и адреса его начального элемента) на разных линиях кэш-памяти.

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

28

Page 29: C++ осень 2013 лекция 2

Оптимизация загрузки кэш-памяти команд: асимметрия условий (1 / 2)

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

Ложные предсказания переходов делают условные выражения крайне неэффективными.

Асимметричные (статистически смещенные в истинную или ложную сторону) условные выражения становятся причиной ложного предсказания переходов и «пузырей» (периодов ожидания ресурсов) в конвейере инструкций ЦП.

Реже исполняемый код должен быть вытеснен с основного вычислительного пути.

29

Page 30: C++ осень 2013 лекция 2

Оптимизация загрузки кэш-памяти команд: асимметрия условий (2 / 2)

Первый и наиболее очевидный способ повышения эффективности загрузки кэш-памяти L1i — явная реорганизация блоков. Так, если условие 𝑃 𝑥 чаще оказывается ложным, оператор вида:

if(P(x)) statementA; else statementB;

должен быть преобразован к виду:

if(!(P(x))) statementB; else statementA;

Второй способ решения той же проблемы основан на применении средств, предоставляемых GCC:

перекомпиляция с учетом результатов профилирования кода;

использование функции __builtin_expect.

30

Page 31: C++ осень 2013 лекция 2

Функция __builtin_expect (GCC)

Функция __builtin_expect — одна из целого ряда встроенных в GCC функций, предназначенных для целей оптимизации:

long __builtin_expect(long exp, long c);

снабжает компилятор информацией, связанной с предсказанием переходов,

сообщает компилятору, что наиболее вероятным значением выражения exp является c, и возвращает exp.

При использовании __builtin_expect с логическими операциями имеет смысл ввести дополнительные макроопределения вида:

#define unlikely(expr) __builtin_expect(!!(expr), 0)

#define likely(expr) __builtin_expect(!!(expr), 1)

31

Page 32: C++ осень 2013 лекция 2

Функция __builtin_expect (GCC): пример

32

#define unlikely(expr) __builtin_expect(!!(expr), 0)

#define likely(expr) __builtin_expect(!!(expr), 1)

int a;

srand(time(NULL));

a = rand() % 10;

if(unlikely(a > 8)) // условие ложно в 80% случаев

foo();

else

bar();

Page 33: C++ осень 2013 лекция 2

Оптимизация загрузки кэш-памяти команд: встраивание функций

Эффективность встраивания функций объясняется способностью компилятора одновременно оптимизировать бóльшие кодовые фрагменты. Порождаемый же при этом машинный код способен лучше задействовать конвейерную архитектуру микропроцессора.

Обратной стороной встраивания является увеличение объема кода и бóльшая нагрузка на кэш-память команд всех уровней (L1i, L2i, …), которая может привести к общему снижению производительности.

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

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

33

Page 34: C++ осень 2013 лекция 2

GCC-атрибуты always_inline и noinline: пример

34

// пример 1: принудительное встраивание

/* inline */ __attribute__((always_inline)) void foo()

{

// ...

}

// пример 2: принудительный запрет встраивания

__attribute__((noinline)) void bar()

{

// ...

}

Page 35: C++ осень 2013 лекция 2

Утилита pfunct. Формат DWARF

Утилита pfunct позволяет анализировать объектный код на уровне функций, в том числе определять:

количество безусловных переходов на метку (goto), параметров и размер функций;

количество функций, определенные как рекомендуемые к встраиванию (inline), но не встроенных компилятором, и наоборот.

Работа pfunct (как и описанной ранее утилиты pahole) строится на использовании расположенной в ELF-файле (Executable and Linkage Format) отладочной информации, хранящейся в нем по стандарту DWARF, который среди прочих компиляторов использует GCC:

отладочная информация хранится в разделе debug_info в виде иерархии тегов и значений , представляющих переменные, параметры функций и пр. См. также утилиты readelf (binutils) и eu-readelf (elfutils).

35

Page 36: C++ осень 2013 лекция 2

Системные аспекты выделения и освобождения памяти

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

упрощает создание и использование структур данных произвольной длины;

позволяет избегать «утечек» оперативной памяти;

разрабатывать высокопроизводительный код.

В частности, необходимо знать о существовании 4-частной структуры памяти данных:

область данных, сегмент BSS и куча (входят в сегмент данных);

программный стек (не входит в сегмент данных).

36

Page 37: C++ осень 2013 лекция 2

Область данных и сегмент BSS

Область данных (data area) — используется для переменных со статической продолжительностью хранения, которые явно получили значение при инициализации: делится на область констант (read-only area) и область чтения-записи

(read-write area);

инициализируется при загрузке программы, но до входа в функцию main на основании образа соответствующих переменных в объектном файле.

Сегмент BSS (BSS segment, .bss) — предназначен для переменных со статической продолжительностью хранения, не получивших значение при инициализации (инициализированы нулевыми битами): располагается «выше» области данных (занимает более старшие адреса);

ОС по требованию загрузчика, но до входа в функцию main способна эффективно заполнять его нулями в блочном режиме (zero-fill-on-demand).

37

Page 38: C++ осень 2013 лекция 2

Куча и программный стек

Куча (heap) — контролируется программистом посредством вызова, в том числе, функций malloc / free:

располагается «выше» сегмента BSS;

является общей для всех разделяемых библиотек и динамически загружаемых объектов (DLO, dynamically loaded objects) процесса.

Программный стек (stack) — содержит значения, передаваемые функции при ее вызове (stack frame), и автоматические переменные:

следует дисциплине LIFO;

растет «вниз» (противоположно куче);

обычно занимает самые верхние (максимальные) адреса виртуального адресного пространства.

38

Page 39: C++ осень 2013 лекция 2

Функция malloc

Внутренняя работа POSIX-совместимых функций malloc / free зависит от их реализации (в частности, дисциплины выделения памяти):

одним из самых известных распределителей памяти в ОС семейства UNIX (BSD 4.3 и др.) является распределитель Мак-Кьюсика — Карелса (McKusick–Karels allocator).

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

размер выделенного блока;

ссылку на следующий блок памяти в связанном списке блоков.

39

Page 40: C++ осень 2013 лекция 2

Функция free (1 / 2)

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

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

40

Page 41: C++ осень 2013 лекция 2

Функция free (2 / 2)

В ходе своей работы функция free:

идентифицирует выделенный блок памяти;

возвращает его в список свободных блоков;

предпринимает попытку слияния смежных свободных блоков для снижения фрагментации и повышения вероятности успешного выделения в будущем фрагмента требуемого размера.

Такая логика работы пары malloc / free избавляет от необходимости передачи длины освобождаемого блока как самостоятельного параметра.

41

Page 42: C++ осень 2013 лекция 2

Стандарт POSIX

POSIX (Portable Operating System Interface for UNIX) — набор стандартов, разработанных IEEE и Open Group для обеспечения совместимости ОС через унификацию их интерфейсов с прикладными программами (API), а также переносимость самих прикладных программ на уровне исходного кода на языке C.

Стандарт IEEE 1003 содержит четыре основных компонента: «Основные определения» (том XBD);

«Системные интерфейсы» (том XSH) — в числе прочего описывает работу с сигналами, потоками исполнения (threads), потоками В/В (I/O streams), сокетами и пр., а также содержит информацию обо всех POSIX-совместимых системных подпрограммах и функциях;

«Оболочка и утилиты» (том XCU);

«Обоснование» (том XRAT).

POSIX-совместимыми ОС являются IBM AIX, OpenSolaris, QNX и др.

42

Page 43: C++ осень 2013 лекция 2

Практикум №2

43

Постановка задачи

Решить индивидуальные задачи №№3, 4 и 5 в соответствии с формальными требованиями.

Для этого в блоге дисциплины:

узнать постановку задач.

Page 44: C++ осень 2013 лекция 2

Спасибо за внимание

Алексей Петров