Нет ускорения в многопоточной программе

Я играл с языком Go concurrency и нашел нечто непрозрачное для меня.

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

Вот программа Java

public static double[][] parallelMultiply(int nthreads, final double[][] m1, final double[][] m2) {
    final int n = m1.length, m = m1[0].length, l = m2[0].length;
    assert m1[0].length == m2.length;

    double[][] r = new double[n][];

    ExecutorService e = Executors.newFixedThreadPool(nthreads);
    List<Future<double[]>> results = new LinkedList<Future<double[]>>();
    for (int ii = 0; ii < n; ++ii) {
        final int i = ii;
        Future<double[]> result = e.submit(new Callable<double[]>() {
            public double[] call() throws Exception {
                double[] row = new double[l];
                for (int j = 0; j < l; ++j) {
                    for (int k = 0; k < m; ++k) {
                        row[j] += m1[i][k]*m2[k][j];
                    }
                }
                return row;
            }
        });
        results.add(result);
    }
    try {
        e.shutdown();
        e.awaitTermination(1, TimeUnit.HOURS);
        int i = 0;
        for (Future<double[]> result : results) {
            r[i] = result.get();
            ++i;
        }
    } catch (Exception ex) {
        ex.printStackTrace();
        return null;
    }

    return r;
}

и это программа Go

type Matrix struct {
    n, m int
    data [][]float64
}

func New(n, m int) *Matrix {
    data := make([][]float64, n)
    for i, _ := range data {
        data[i] = make([]float64, m)
    }
    return &Matrix{n, m, data}
}

func (m *Matrix) Get(i, j int) float64 {
    return m.data[i][j]
}

func (m *Matrix) Set(i, j int, v float64) {
    m.data[i][j] = v
}

func MultiplyParallel(m1, m2 *Matrix) *Matrix {
    r := New(m1.n, m2.m)

    c := make(chan interface{}, m1.n)
    for i := 0; i < m1.n; i++ {
        go func(i int) {
            innerLoop(r, m1, m2, i)
            c <- nil
        }(i)
    }

    for i := 0; i < m1.n; i++ {
        <-c
    }

    return r
}

func innerLoop(r, m1, m2 *Matrix, i int) {
    for j := 0; j < m2.m; j++ {
        s := 0.0
        for k := 0; k < m1.m; k++ {
            s = s + m1.Get(i, k) * m2.Get(k, j)
        }
        r.Set(i, j, s)
    }
}

Когда я использую Java-программу с nthreads = 1 и nthreads = 2, у меня двухъядерный N450 Atom нетбук почти удваивается. Когда я использую программу Go с GOMAXPROCS = 1 и GOMAXPROCS = 2, ускорение вообще не выполняется!

Несмотря на то что Java-код использует дополнительное хранилище для Future, а затем собирает их значения в матрицу результатов вместо прямого обновления массива в рабочем коде (что делает версия Go), он выполняет гораздо более быстрые операции на нескольких ядрах, чем Go версия.

Особенно забавно, что версия Go с GOMAXPROCS = 2 загружает оба ядра (htop отображает 100% нагрузку на оба процессора во время работы программы), но время вычисления такое же, как у GOMAXPROCS = 1 (htop отображает только 100% нагрузку на одном ядре в этом случае).

Еще одна проблема заключается в том, что Java-программа быстрее, чем Go, даже в простом однопоточном умножении, но это не совсем неожиданно (взятие тестов из здесь) и не должен влиять на множитель многоядерности.

Что я здесь делаю неправильно? Есть ли способ ускорить программу Go?

UPD: Кажется, я нашел то, что делаю неправильно. Я проверял время java-программы, используя System.currentTimeMillis() и программу Go, используя команду time shell. Я ошибочно принял "пользовательское" время от выхода zsh в качестве рабочего времени программы вместо "общего". Теперь я дважды проверял скорость вычислений, и это дает мне почти двойное ускорение (хотя оно слабее меньше, чем Java):

% time env GOMAXPROCS=2 ./4-2-go -n 500 -q
env GOMAXPROCS=2 ./4-2-go -n 500 -q  22,34s user 0,04s system 99% cpu 22,483 total
% time env GOMAXPROCS=2 ./4-2-go -n 500 -q -p
env GOMAXPROCS=2 ./4-2-go -n 500 -q -p  24,09s user 0,10s system 184% cpu 13,080 total

Кажется, я должен быть более внимательным.

Все еще java-программа дает пять раз меньше времени в одном и том же случае. Но это вопрос другого вопроса, который я думаю.

Ответ 1

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

Такой вид кеша "пинг-понг" чрезвычайно сложно диагностировать и может произойти по логически полностью несвязанным данным, просто потому, что они находятся достаточно близко в памяти. 100% загрузка процессора типична для ложного обмена - ваши ядра действительно работают на 100%, они просто не работают над вашей программой - они работают над синхронизацией своих кешей.

Тот факт, что в программе Java у вас есть поточно-частные данные, пока не придет время "интегрировать" его в конечный результат, это то, что избавляет вас от ложного обмена. Я не знаком с Go, но, судя по вашим собственным словам, потоки пишут непосредственно в общий массив, что в точности является тем, что может вызвать ложный обмен. Это пример того, как совершенно правильное однопоточное рассуждение делает в противоположном случае многопоточную среду!

Для более подробного обсуждения этой темы я горячо рекомендую статью Херба Саттера: Устранить ложное разделение или лекцию: Архитектура машины: все, что ваш язык программирования никогда не говорил вам (и связанный слайды PDF).

Ответ 2

Если вы можете запустить этот код в среде Linux, вы можете использовать perf для измерения эффекта ложного обмена.

Ответ 3

Для Linux, Windows 32 и ditto 64 есть также AMD CodeXL и CodeAnalyst. Они будут профилировать приложение, работающее на процессоре AMD, гораздо более подробно, чем одно из Intel, поскольку соответствующие регистры производительности отличаются.