Разреженное матрично-плотное векторное умножение с матрицей, известной во время компиляции

У меня есть разреженная матрица, содержащая только нули и единицы в качестве записей (и, например, с формой 32k x 64k и 0,01% ненулевых записей и без шаблонов для использования с точки зрения того, где находятся ненулевые записи). Матрица известна во время компиляции. Я хочу выполнить умножение матрицы на вектор (по модулю 2) с неразреженными векторами (неизвестными во время компиляции), содержащими 50% единиц и нулей. Я хочу, чтобы это было эффективно, в частности, я пытаюсь использовать тот факт, что матрица известна во время компиляции.

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

constexpr std::size_t N = 64'000;
constexpr std::size_t M = 32'000;

template<typename Bit>
void multiply(const std::array<Bit, N> &in, std::array<Bit, M> &out) {
    out[0] = (in[11200] + in[21960] + in[29430] + in[36850] + in[44352] + in[49019] + in[52014] + in[54585] + in[57077] + in[59238] + in[60360] + in[61120] + in[61867] + in[62608] + in[63352] ) % 2;
    out[1] = (in[1] + in[11201] + in[21961] + in[29431] + in[36851] + in[44353] + in[49020] + in[52015] + in[54586] + in[57078] + in[59239] + in[60361] + in[61121] + in[61868] + in[62609] + in[63353] ) % 2;
    out[2] = (in[11202] + in[21962] + in[29432] + in[36852] + in[44354] + in[49021] + in[52016] + in[54587] + in[57079] + in[59240] + in[60362] + in[61122] + in[61869] + in[62610] + in[63354] ) % 2;
    out[3] = (in[56836] + in[11203] + in[21963] + in[29433] + in[36853] + in[44355] + in[49022] + in[52017] + in[54588] + in[57080] + in[59241] + in[60110] + in[61123] + in[61870] + in[62588] + in[63355] ) % 2;
    // LOTS more of this...
    out[31999] = (in[10208] + in[21245] + in[29208] + in[36797] + in[40359] + in[48193] + in[52009] + in[54545] + in[56941] + in[59093] + in[60255] + in[61025] + in[61779] + in[62309] + in[62616] + in[63858] ) % 2;
}

Это на самом деле работает (требуется много времени для компиляции). Однако на самом деле он кажется очень медленным (более чем в 10 раз медленнее, чем то же разреженное векторно-матричное умножение в Джулии), а также значительно увеличивает размер исполняемого файла, чем я считал необходимым. Я пробовал это как с std::array, так и с std::vector, а также с отдельными записями (представленными как Bit), равными bool, std::uint8_t и int, но никакого прогресса, о котором стоит упомянуть. Я также попытался заменить модуль и добавить XOR. В заключение скажу, что это ужасная идея. Однако я не уверен, почему - настолько ли размер кода замедляет его? Исключает ли такой код оптимизацию компилятора?

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

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

Есть ли у вас другие идеи о том, как это можно сделать?


person Adomas Baliuka    schedule 24.04.2021    source источник
comment
Как правило, если вам нужно спросить, как превзойти хорошо реализованное математическое ядро, вы не превзойдете хорошо реализованное математическое ядро. Просто используйте OpenBLAS или MKL.   -  person CJR    schedule 25.04.2021
comment
@CJR причина, по которой я (думаю, что я) хочу избегать использования таких библиотек, заключается в том, что, во-первых, я пытался использовать тот факт, что матрица известна во время компиляции, а во-вторых, я рассматриваю возможность включения этого в конечном итоге микроконтроллер, поэтому я бы предпочел не компилировать в ARM и не связывать большие библиотеки, когда я использую только одну функцию из них. Ответ Жерома Ришара, похоже, предполагает, что в этом случае нет возможности использовать знания времени компиляции. Будет ли использование числовой библиотеки (я не использую числа с плавающей запятой!) жизнеспособным на встроенной платформе и, возможно, сделает ее быстрее?   -  person Adomas Baliuka    schedule 25.04.2021


Ответы (2)


Однако я не уверен, почему - настолько ли размер кода замедляет его?

Проблема в том, что исполняемый файл большой, ОС будет загружать много страниц с вашего устройства хранения. Этот процесс очень медленный. Процессор часто зависает в ожидании загрузки данных. И даже если бы код был уже загружен в ОЗУ (кеширование ОС), это было бы неэффективно, потому что скорость ОЗУ (латентность + пропускная способность) совсем плохая. Основная проблема здесь в том, что все инструкции выполняются только один раз. Если вы повторно используете функцию, то код необходимо перезагрузить из кеша, а если он слишком велик, чтобы поместиться в кеш, он будет загружен из медленной оперативной памяти. Таким образом, накладные расходы на загрузку кода очень высоки по сравнению с его фактическим выполнением. Чтобы решить эту проблему, вам нужно использовать довольно небольшой код с циклами, повторяющимися на довольно небольшом количестве данных.

Исключает ли такой код оптимизацию компилятора?

Это зависит от компилятора, но большинство основных компиляторов (например, GCC или Clang) оптимизируют код таким же образом (отсюда и медленное время компиляции).

Как вы думаете, стоит ли это попробовать (думаю, я все равно попробую в понедельник)?

Да, это решение явно лучше, особенно если индексы хранить компактно. В вашем случае вы можете хранить их, используя тип uint16_t. Все индексы можно поместить в большой буфер. Начальное/конечное положение индексов для каждой строки можно указать в другом буфере, ссылаясь на первый (или используя указатели). Этот буфер можно загрузить один раз в память в начале вашего приложения из специального файла, чтобы уменьшить размер результирующей программы (и избежать выборки с устройства хранения в критическом цикле). С вероятностью 0,01% наличия ненулевых значений результирующая структура данных займет менее 500 КиБ ОЗУ. На среднестатистическом процессоре для настольных ПК он может поместиться в кэше L3 (что довольно быстро), и я думаю, что ваши вычисления не должны занимать более 1 мс, если предположить, что код multiply тщательно оптимизирован.

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

Битовая упаковка хороша только в том случае, если ваша матрица не слишком разрежена. С матрицей, заполненной 50% ненулевыми значениями, метод битовой упаковки великолепен. С 0,01% ненулевых значений метод битовой упаковки явно плох, так как занимает слишком много места.

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

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

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

введите здесь описание изображения

person Jérôme Richard    schedule 24.04.2021
comment
Спасибо за ответ! Я попробую зациклиться на разреженном хранилище. Однако ваш ответ, похоже, предполагает, что нет возможности использовать тот факт, что матрица известна во время компиляции. Почему-то мне это кажется контринтуитивным. Кроме того, в любом случае должен быть способ хранения матрицы в исполняемом файле без потери производительности? - person Adomas Baliuka; 25.04.2021
comment
Я согласен, что это звучит нелогично, но матрица действительно велика, а шаблон доступа кажется случайным, что не позволяет проводить многие расширенные оптимизации во время компиляции. Ваши вычисления могут быть оптимизированы на самой аппаратной стороне, но, насколько я знаю, не очень на программном обеспечении. Современные процессоры могут выполнять очень сложные оптимизации, устраняя большую часть накладных расходов на циклы, а иногда даже на динамические операции (например, перемещение данных между регистрами). Однако на современных компьютерах действительно огромной проблемой является память и, в частности, передача данных. Они намного дороже вычислений, поэтому их следует избегать. - person Jérôme Richard; 25.04.2021
comment
Вы можете сохранить его в исполняемом файле без потери производительности, но для этого вам нужно загрузить данные матрицы раньше, чтобы ОС могла получить их с устройства хранения (ОС ленивы: они перемещают данные только тогда, когда это необходимо) . Пока матрица загружается в ОЗУ и четко помещается в кеш, это нормально. - person Jérôme Richard; 25.04.2021
comment
вам нужно загрузить данные матрицы, прежде чем я не понимаю, что это значит. Загрузить перед чем? Загрузить как? Мой текущий план состоит в том, чтобы попытаться сохранить представление сжатого разреженного столбца (кроме массива значений, поскольку все значения равны 1) в виде двух массивов constexpr, где я снова автоматически сгенерирую большой .cpp файл, определяющий их. Вы бы сделали это по-другому? - person Adomas Baliuka; 25.04.2021
comment
Ваш исполняемый файл не полностью загружается в ОЗУ вашей ОС при его запуске. Чтобы заставить матрицу загружаться в оперативную память, вам нужно прочитать ее в своей программе (например, в основной перед чем-либо например). Если multiply вызывается много раз или не требует малой задержки, это может не быть проблемой. Ваше решение должно работать, но оно сильно нагружает компилятор. Некоторые компиляторы могут давать сбои или вызывать ошибки, когда вы определяете огромные константные массивы в файлах cpp. Загрузка данных из бинарного (сжатого) файла во время запуска (в начале основного) должна быть более безопасной и, возможно, такой же быстрой. - person Jérôme Richard; 25.04.2021
comment
@JérômeRichard Этот образ о задержке довольно хорош - person dreamcrash; 25.04.2021
comment
@dreamcrash Да (но, к сожалению, не совсем актуально ^^'') - person Jérôme Richard; 27.04.2021

Я реализовал второй метод (массивы constexpr, хранящие матрицу в формате хранения сжатых столбцов), и он намного лучше. Для компиляции с -O3 требуется (для двоичной матрицы 64 000 x 22 000, содержащей 35 000 единиц) 1 минута, а на моем ноутбуке выполняется одно умножение за ‹300 микросекунд (у Джулии на то же вычисление уходит около 350 микросекунд). Общий размер исполняемого файла составляет ~1 Мбайт.

Вероятно, можно еще сделать намного лучше. Если у кого-то есть идея, дайте мне знать!

Ниже приведен пример кода (показывающий матрицу 5x10), иллюстрирующий то, что я сделал.

#include <iostream>
#include <array>

// Compressed sparse column storage for binary matrix
constexpr std::size_t M = 5;
constexpr std::size_t N = 10;
constexpr std::size_t num_nz = 5;
constexpr std::array<std::uint16_t, N + 1> colptr = {
0x0,0x1,0x2,0x3,0x4,0x5,0x5,0x5,0x5,0x5,0x5
};
constexpr std::array<std::uint16_t, num_nz> row_idx = {
0x0,0x1,0x2,0x3,0x4
};

template<typename Bit>
constexpr void encode(const std::array<Bit, N>& in, std::array<Bit, M>& out) {

    for (std::size_t col = 0; col < N; col++) {
        for (std::size_t j = colptr[col]; j < colptr[col + 1]; j++) {
            out[row_idx[j]] = (static_cast<bool>(out[row_idx[j]]) != static_cast<bool>(in[col]));
        }
    }
}

int main() {
    using Bit = bool;
    std::array<Bit, N> input{1, 0, 1, 0, 1, 1, 0, 1, 0, 1};
    std::array<Bit, M> output{};
    
    for (auto i : input) std::cout << i;
    std::cout << std::endl;

    encode(input, output);

    for (auto i : output) std::cout << i;
}
person Adomas Baliuka    schedule 25.04.2021