Windows и C++

Visual C++ 2010 и библиотека шаблонов для разработки с учетом параллельных вычислений

Кенни Керр (Kenny Kerr)

Эта статья основана на предварительной версии Visual Studio 2010. Любые содержащиеся в ней сведения могут быть изменены.

Содержание

Усовершенствования языка
Параллельные алгоритмы
Задания и группы заданий

Версия Visual Studio 2010 станет существенным обновлением Visual C++. Многие из новых выразительных средств языка и библиотеки разработаны исключительно для того, чтобы можно было проще и естественнее выражать свои намерения в коде. Но, как и всегда было верно для C++, именно сочетание возможностей делает его таким мощным и выразительным языком.

В этом месяце я собираюсь представить некоторые из дополнений к языку C++, которые были добавлены к Visual C++ как часть будущего стандарта C++0x. Затем будет рассмотрена библиотека шаблонов для разработки с учетом параллельных вычислений (Parallel Patterns Library – PPL), созданная корпорацией Майкрософт в дополнение к стандарту C++0x. Эта библиотека позволяет внедрять параллелизм в разрабатываемые приложения так же естественно, как если бы она была частью стандартной библиотеки C++.

Усовершенствования языка

В статье, вышедшей в мае 2008 года, «C++ ПЛЮС: Улучшение приложений Windows с пакетом компонентов Visual C++ 2008», читатели узнали о дополнениях к стандартной библиотеке C++ в качестве части технического отчета 1 (TR1). Изначально эти дополнения были представлены в пакете компонентов Visual C++ 2008 и сейчас входят в состав пакета обновления 1 для Visual Studio 2008. В той статье я рассказывал о поддержке объектов функций при помощи класса шаблона функции и функции привязки шаблона. Возможность полиморфного обращения с функциями избавила от многих неудобств, с которыми часто сталкивались разработчики C++ при написании или использовании обобщенных алгоритмов.

Напомню, как инициализируется объект функции стандартным алгоритмом сложения:

function<int (int, int)> f = plus<int>();
int result = f(4, 5);

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

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

struct Adder
{
   int Add(int x, int y, void* /*reserved*/)
   {
       return x + y;
   }
};

Adder adder;
function<int (int, int)> f = bind(&Adder::Add, &adder, _1, _2, 
    static_cast<void*>(0));
int result = f(4, 5);

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

Именно в этом может помочь новое ключевое слово auto. Его можно использовать вместо явного определения типа переменной, что бывает полезно при метапрограммировании шаблонов, когда конкретные типы либо трудно определить, либо сложно выразить. Вот как это выглядит:

auto f = plus<int>();

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

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

auto f = [](int x, int y) { return x + y; };

С помощью лямбда-выражений определяются безымянные объекты функций, иногда называемые замыканиями. «[]» – это подсказка, указывающая компилятору на начало лямбда-выражения. Эта конструкция известна как лямбда-маркер. За ним следует список параметров. В объявление параметров может входить тип возврата, хотя если компилятор может однозначно вычислить его тип, то он часто опускается, как в случае предыдущего фрагмента кода. Если функция не должна принимать параметры, то и сам список параметров может быть опущен. Лямбда-выражение завершается любым числом операторов C++, заключенных в фигурные скобки.

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

int sum = 0;
for_each(values.begin(), values.end(), [&sum](int value)
{
    sum += value;
});

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

Параллельные алгоритмы

В PPL представлен набор проблемно-ориентированных конструкций, облегчающих использование параллельных вычислений, а также некоторое количество параллельных алгоритмов, подобных доступным сейчас в OpenMP. Но в отличие от OpenMP, алгоритмы PPL написаны с использованием шаблонов C++, а не директив pragma, поэтому они намного более выразительны и гибки. Кроме того, PPL содействует распространению в качестве приемов проектирования такого набора примитивов и алгоритмов, которые более пригодны к повторному использованию и комбинированию. Этим PPL фундаментально отличается от OpenMP. В то же время библиотека OpenMP по своей природе более декларативна, в ней более явно выражена некоторая функциональность, например планирование, и, в конечном счете, OpenMP не является частью собственно C++. PPL также построена на основе среды параллельного выполнения процессов, что открывает большие потенциальные возможности взаимодействия с другими библиотеками, основанными на той же среде выполнения. Рассмотрим подробнее алгоритмы PPL, чтобы увидеть, как можно использовать лежащую в основе библиотеки функциональность для проблемно-ориентированного параллелизма.

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

Рис. 1. Алгоритм преобразования в оттенки серого с использованием OpenMP

struct Pixel
{
    BYTE Blue;
    BYTE Green;
    BYTE Red;
    BYTE Alpha;
};

void MakeGrayscale(Pixel& pixel)
{
    const BYTE scale = static_cast<BYTE>(0.30 * pixel.Red +
                                         0.59 * pixel.Green +
                                         0.11 * pixel.Blue);

    pixel.Red = scale;
    pixel.Green = scale;
    pixel.Blue = scale;
}

void MakeGrayscale(BYTE* bitmap,
                   const int width,
                   const int height,
                   const int stride)
{
    #pragma omp parallel for
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            const int offset = x * sizeof(Pixel) + y * stride;

            Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);

            MakeGrayscale(pixel);
        }
    }
}

В PPL входят алгоритмы параллельного цикла, которые с небольшой помощью лямбда-выражений самым естественным образом заменяют использование OpenMP в функции MakeGrayscale на рис. 1:

parallel_for(0, height, [&] (int y)
{
    for (int x = 0; x < width; ++x)
    {
        // omitted for brevity
    }
});

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

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

 [&bitmap, width, stride]

Так же как функция parallel_for является параллельной альтернативой цикла for, предоставляя параллельный перебор диапазона индексов, функция-шаблон библиотеки PPL parallel_for_each является параллельной альтернативой стандартной функции for_each. Она обеспечивает параллельный перебор диапазона, который определен парой итераторов — например таких, которые предоставляют стандартные контейнеры. Хотя для предыдущего примера имело бы больше смысла использовать функцию parallel_for с явно заданными индексами, часто более естественно использовать итераторы для обозначения диапазона элементов. Значения чисел в заданном массиве можно возвести в квадрат, следующим образом используя функцию parallel_for:

array<int, 5> values = { 1, 2, 3, 4, 5 };

parallel_for(0U, values.size(), [&values] (size_t i)
{
    values[i] *= 2;
});

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

parallel_for_each(values.begin(), values.end(), [] (int& value)
{
    value *= 2;
});

Если требуется одновременно выполнить несколько функций или выполнить их настолько одновременно, насколько позволяет количество аппаратных потоков, то можно использовать функцию-шаблон parallel_invoke. Для этой функции-шаблона доступны перегрузки, принимающие от 2 до 10 объектов функции. Вот пример параллельного выполнения 3 лямбда-функций:

combinable<int> sum;

parallel_invoke([&] { sum.local() += 1; },
                [&] { sum.local() += 2; },
                [&] { sum.local() += 3; });

int result = sum.combine([] (int left, int right)
{
    return left + right;
});

ASSERT(6 == result);

Этот пример также иллюстрирует другой вспомогательный класс, предоставляемый PPL. С помощью комбинируемого класса очень просто собирать результаты ряда параллельных задач с минимальной блокировкой. Каждый поток работает с локальной копией значения, а результаты каждого потока объединяются только после завершения параллельной работы. Таким образом удается избежать большей части блокировок, которые обычно возникают в таких случаях.

Задания и группы заданий

Собственно параллелизм в алгоритмах, о которых я рассказал, достигается с помощью простого проблемно-ориентированного API, который можно использовать напрямую. Задания определяются классом task_handle, который инициализируется объектом функции, и сводятся в группу с помощью класса task_group, который выполняет задания и ожидает их завершения. Естественно, класс task_group предоставляет полезные перегрузки, так что во многих случаях объекты task_handle даже не требуется определять самому — объект task_group может их выделять и управлять временем их существования. Вот пример того, как использовать task_group для замены функции parallel_invoke из предыдущего примера:

task_group group;
group.run([&] { sum.local() += 1; });
group.run([&] { sum.local() += 2; });
group.run([&] { sum.local() += 3; });
group.wait();

В дополнение к алгоритмам и API, которые были здесь обсуждены, к моменту выхода библиотеки шаблонов для разработки с учетом параллельных вычислений в состав Visual C++ 2010 вполне могут быть включены другие параллельные алгоритмы и вспомогательные классы. Чтобы оставаться в курсе новостей параллелизма, посещайте Parallel Programming in Native Code (Параллельное программирование в машинном коде).

Вопросы и комментарии направляйте по адресу mmwincpp@microsoft.com.

Кенни Керр (Kenny Kerr) — специалист в области разработки программного обеспечения для Windows. Он с большим энтузиазмом занимается написанием статей по программированию и проектированию программного обеспечения, а также обучением разработчиков. Связаться с Кенни можно через блог по адресу weblogs.asp.net/kennykerr.