Как многопоточный "хвостовой вызов" рекурсия с помощью TBB

Я пытаюсь использовать tbb для многопоточного существующего рекурсивного алгоритма. Однопоточная версия использует рекурсию хвостового вызова, структурно она выглядит примерно так:

void my_func() {
    my_recusive_func (0);
}

bool doSomeWork (int i, int& a, int& b, int& c) {
    // do some work
}

void my_recusive_func (int i) {
    int a, b, c;
    bool notDone = doSomeWork (i, a, b, c);
    if (notDone) {
        my_recusive_func (a);
        my_recusive_func (b);
        my_recusive_func (c);
    }
}

Я новичок tbb, поэтому моя первая попытка использовала функцию parallel_invoke:

void my_recusive_func (int i) {
    int a, b, c;
    bool notDone = doSomeWork (i, a, b, c);
    if (notDone) {
        tbb::parallel_invoke (
                [a]{my_recusive_func (a);},
                [b]{my_recusive_func (b);},
                [c]{my_recusive_func (c);});
    }
}

это работает, и он работает быстрее, чем однопоточная версия, но, похоже, не масштабируется с количеством ядер. Машина, на которую я нацелен, имеет 16 ядер (32 hyper-threads), поэтому масштабируемость очень важна для этого проекта, но эта версия в лучшем случае ускоряется в 8 раз, и многие ядра кажутся простаивающими во время работы алгоритма.

моя теория заключается в том, что tbb ждет завершения дочерних задач после parallel_invoke, поэтому может быть много задач, сидящих без дела, ожидая без необходимости? Это объяснило бы бездействующие ядра? Есть ли способ заставить родительскую задачу вернуться, не дожидаясь детей? Я, возможно, думал что-то вроде этого, но я еще недостаточно знаю о планировщике, чтобы знать, хорошо это или нет:

void my_func()
{
    tbb::task_group g;
    my_recusive_func (0, g);
    g.wait();
}

void my_recusive_func (int i, tbb::task_group& g) {
    int a, b, c;
    bool notDone = doSomeWork (i, a, b, c);
    if (notDone) {
        g.run([a,&g]{my_recusive_func(a, g);});
        g.run([b,&g]{my_recusive_func(b, g);});
        my_recusive_func (c, g);
    }
}

мой первый вопрос составляет tbb::task_group::run() потокобезопасным? Я не мог понять этого из документации. Кроме того, есть ли лучший способ сделать это? Возможно, я должен использовать вызовы планировщика низкого уровня вместо этого?

(Я ввел этот код без компиляции, поэтому, пожалуйста, простите опечатки.)

3 ответов


здесь действительно два вопроса:

  1. является ли реализация TBB task_group:: run потокобезопасной? Да. (Мы должны документировать это более четко).
  2. имеет много потоков, вызывающих метод run () на то же самое task_group масштабируемой? Нет. (Я считаю, что документация Microsoft упоминает об этом где-то.) Причина в том, что task_group становится центральной точкой раздора. Это просто выборка и добавление в реализации, но это все еще в конечном счете неразрешимо, так как затронутая строка кэша должна отскакивать.

обычно лучше всего создавать небольшое количество задач из task_group. При использовании рекурсивного параллелизма дайте каждому уровню свою собственную task_group. Хотя производительность, скорее всего, не будет лучше, чем при использовании parallel_invoke.

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

но я немного обеспокоен холостыми потоками. Мне интересно, достаточно ли работы, чтобы держать потоки занятыми. Рассмотреть возможность анализ рабочей области первый. Если вы используете компилятор Intel (или gcc 4.9), вы можете попробовать сначала поэкспериментировать с версией Cilk. Если это не ускорится, то даже низкоуровневый интерфейс tbb::task вряд ли поможет, и проблемы более высокого уровня (работа и диапазон) должны быть рассмотрены.


я уверен, что tbb::task_group::run() является потокобезопасным. Я не могу найти упоминания в документации, что довольно удивительно.

однако,

  • это сообщение в блоге 2008 содержит примитивную реализацию task_group, которого run() метод явно отмечен как потокобезопасный. Текущая реализация очень похожа.
  • код проверки для tbb::task_group (in src/test/test_task_group.cpp) поставляется с тест предназначен для проверки потокобезопасность task_group (он порождает кучу потоков, каждый из которых вызывает run() тысячу раз или больше на одном и том же

Вы можете осуществить это следующим образом:

constexpr int END = 10;
constexpr int PARALLEL_LIMIT = END - 4;
static void do_work(int i, int j) {
    printf("%d, %d\n", i, j);
}

static void serial_recusive_func(int i, int j) {
    // DO WORK HERE
    // ...
    do_work(i,j);
    if (i < END) {
        serial_recusive_func(i+1, 0);
        serial_recusive_func(i+1, 1);
        serial_recusive_func(i+1, 2);
    }
}

class RecursiveTask : public tbb::task {
    int i;
    int j;
public:
    RecursiveTask(int i, int j) :
        tbb::task(),
        i(i), j(j)
    {}
    task *execute() override {
        //DO WORK HERE
        //...
        do_work(i,j);
        if (i >= END) return nullptr;
        if (i < PARALLEL_LIMIT) {
            auto &c = *new (allocate_continuation()) tbb::empty_task();
            c.set_ref_count(3);
            spawn(*new(c.allocate_child()) RecursiveTask(i+1, 0));
            spawn(*new(c.allocate_child()) RecursiveTask(i+1, 1));
            recycle_as_child_of(c);
            i = i+1; j = 2;
            return this;
        } else {
            serial_recusive_func(i+1, 0);
            serial_recusive_func(i+1, 1);
            serial_recusive_func(i+1, 2);
        }
        return nullptr;
    }
};
static void my_func()
{
    tbb::task::spawn_root_and_wait(
        *new(tbb::task::allocate_root()) RecursiveTask(0, 0));
}
int main() {
    my_func();
}

ваш вопрос не включал много информации о "do work here", поэтому моя реализация не дает do_work большая возможность вернуть значение или повлиять на рекурсию. Если вам это нужно, вы должны отредактировать свой вопрос, чтобы включить упоминание о том, какой эффект "do work here" ожидается на общем вычислении.