Настоящая оптимизация на C/C++

от автора

Это продолжение двух постов (первый, второй). В этот раз оптимизация будет произведена на реальной задаче, с правильно построенной методологией тестирования. Задачу возьмем из нашумевшего поста «Алгоритм перевода числа (байтов) в приставку СИ», она интересна тем, на первый взгляд алгоритм тривиальный, в силу математического мышления, а не бинарной арифметики.

Немного исторического отступления, задачи оптимизации появились задолго до компьютерной эпохи, аксиома сводится к тому, что при имеющихся ресурсах (в математике это множество чисел) нужно получить максимум: линейное программирование, симплекс-метод, последовательное квадратичное программирование, это все больше математика, но вернемся к компьютерам. Брайан У. Керниган и Роб Пайк написали замечательную книгу «Практика программирования/The Practice of Programming» 1999 год. В главе 3 в которой тестируется элементарная программа на подсчет слов в тексте, на языках “C” “C++” “Java” “Awk” “Perl”, и приведена красивая табличка:

+-----------------+---------------+--------------------+-----------------------+ |                 | 250МГц,R10000 | 400 МГц,Pentium II | Строк в исходном коде | +-----------------+---------------+--------------------+-----------------------+ | C               | 0,36с         | 0,30с              | 150                   | | Java            | 4,6           | 9,2                | 105                   | | C++/STL/очередь | 2,6           | 11,2               | 70                    | | C++/STL/список  | 1,7           | 1,5                | 70                    | | Awk             | 2,2           | 2,1                | 20                    | | Perl            | 1,8           | 1,0                | 18                    | +-----------------+---------------+--------------------+-----------------------+

На первый взгляд все закономерно, но если читать книгу а не смотреть красивые таблицы, то выясняется что на языке C, был реализован хэш массив для строк, а на C++ использовалась очередь или список вместо хэш, потому что текущая версия их компилятора не поддерживала хэши, при этом идет упоминание что есть компиляторы с хэш контейнерами, хорошо тут можно понять, любители «идиоматичного» кода, но дальше идет откровенный подлог тестирования, для языков Awk и Perl меняется сам алгоритм, за счет этого достигается скорость и малый размер кода, Собственно читатель и сам может увидеть и протестировать код из книги, он есть в открытом доступе по ссылке, что автор и проделал, действительно код на современном железе и компиляторе сохранял относительные коэффициенты скорости, но стоило переписать код C++ на хэш контейнер, и таким образом скорость C и C++ сравнивается, но можно пойти дальше и переписать алгоритм как это сделано на Awk или Perl, в итоге на моей машине код C++ в 3 раза быстрее чем на C.

C++ like perl + оптимизация

#include <iostream> #include <string> #include <unordered_map> #include <vector> std::vector<std::string_view> splitSV(std::string_view str, std::string_view delims = " \t\n\r") {     std::vector<std::string_view> output;     output.reserve(str.size() / 2);     for (auto first = str.data(), second = str.data(), last = first + str.size(); second != last && first != last; first = second + 1) {         second = std::find_first_of(first, last, std::cbegin(delims), std::cend(delims));         if (first != second)             output.emplace_back(first, second - first);     }     return output; }  int main(void) {     srand(time(nullptr));      std::string NONWORD = "\n";     std::string_view w1 = NONWORD;     std::string_view w2 = NONWORD;     std::unordered_map<std::string, std::vector<std::string_view>> statetab;     statetab.reserve(15000);      std::ifstream file("c:\\!\\psalms.txt");     std::vector<char> data(std::istreambuf_iterator<char>(file), {});     auto book = splitSV(std::string_view(data.data(), data.size()));      std::string t;     t.reserve(1000);     for (auto &w3 : book) {         t.clear();         t += w1;         t += w2;         statetab[t].emplace_back(w3);         w2 = w3;         w1 = w2;     }      w1 = w2 = NONWORD;     for (int i = 0; i < 10000; i++) {         t.clear();         t += w1;         t += w2;         auto &vec = statetab[t];         w2 = vec[rand() % vec.size()];         w1 = w2;         //std::cout << w2 << "\n";     }     return 0; }

Задача Алгоритм перевода числа (байтов) в приставку СИ

Java 10-ого основания

// From: https://programming.guide/the-worlds-most-copied-so-snippet.html public static strictfp String humanReadableByteCount(long bytes, boolean si) {     int unit = si ? 1000 : 1024;     long absBytes = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);     if (absBytes < unit) return bytes + " B";     int exp = (int) (Math.log(absBytes) / Math.log(unit));     long th = (long) (Math.pow(unit, exp) * (unit - 0.05));     if (exp < 6 && absBytes >= th - ((th & 0xfff) == 0xd00 ? 52 : 0)) exp++;     String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i");     if (exp > 4) {         bytes /= unit;         exp -= 1;     }     return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre); }

Java 2-ого основания

public static String humanReadableByteCountBin(long bytes) {     long b = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);     return b < 1024L ? bytes + " B"             : b <= 0xfffccccccccccccL >> 40 ? String.format("%.1f KiB", bytes / 0x1p10)             : b <= 0xfffccccccccccccL >> 30 ? String.format("%.1f MiB", bytes / 0x1p20)             : b <= 0xfffccccccccccccL >> 20 ? String.format("%.1f GiB", bytes / 0x1p30)             : b <= 0xfffccccccccccccL >> 10 ? String.format("%.1f TiB", bytes / 0x1p40)             : b <= 0xfffccccccccccccL ? String.format("%.1f PiB", (bytes >> 10) / 0x1p40)             : String.format("%.1f EiB", (bytes >> 20) / 0x1p40); }

Возьмем из поста два кода: один общий для 10-ого основания и второй для 2-ого. Сразу хочу заметить, если код для 10-ого был исправлен для округлений, то код для 2-ого на самом деле не работает, все числа округлятся неверно, поэтому возьмем его лишь для ориентировочного сравнения по скорости.

Сразу бросается в глаза, что код для 10-ого основания выражен через тип double. Известно, что плавающие числа вычисляются дольше чем целые. Заменим код на

std::string sufx[]{"b", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb"}; void bytes_to_print(unsigned long long bytes) {     unsigned long long index{0};     unsigned long long fract;     while (1000 <= bytes) {         fract = bytes;         bytes /= 1000;         index++;     }     fract = fract % 1000 * 1000 / (1000 * 1000 / 100);     std::cout << std::setw(4) << bytes << '.' << std::setfill('0') << std::setw(2) << fract << std::setfill(' ') << ' ' << sufx[index] << '\n'; }

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

std::string sufx[]{"b", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb"}; void bytes_to_print(unsigned long long bytes) {     unsigned long long index = bytes;     index |= index >> 1;     index |= index >> 2;     index |= index >> 4;     index |= index >> 8;     index |= index >> 16;     index |= index >> 32;     index &= 0b0001'0000'0000'0100'0000'0001'0000'0000'0100'0000'0001'0000'0000'0100'0000'0000ull;     index += index << 30;     index += (index << 20) + (index << 10);     index >>= 60;     unsigned long long fract = bytes;      bytes >>= index * 10;     fract >>= (index - 1) * 10;     fract = fract & 0b11'1111'1111'00'0000'0000 / 10485;          std::cout << std::setw(4) << bytes << '.' << std::setfill('0') << std::setw(2) << fract << std::setfill(' ') << ' ' << sufx[index] << '\n'; } 

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

+-------------+---------------+-------------------+ | Алгоритм    | среднее время | критическое время | +-------------+---------------+-------------------+ | Java 10-ого | 3.53c         | 9.43c             | | Java 2-ого  | 2.65c         | 7.43c             | | С++ 10-ого  | 1.81c         | 3.29c             | | C++ 2-ого   | 1.12с         | 1.24c             | +-------------+---------------+-------------------+

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

Литература к прочтению

ссылка на оригинал статьи https://habr.com/ru/post/484082/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *