Працюю із дуже розумними та працьовитими студентами. Тому основне обмеження -- рівень їх мотивації. Важливо показати їм -- навіщо все це. Звісно, власний похід по граблях дає глибший і стабільніший результат, але це довга дорога, хотілося б її спростити. Тому -- очевидні для них приклади плюс практика.
Байка - 1
Типовий "мотиваційний" приклад -- історії про вирішення задачі підрахунку слів у текстових файлах. Фабула наступна, пропонуєш цю задачу новій аудиторії, наступного разу:
- Сильний студент: "дуже складна задача, на 1Мб файлі майже півгодини рахує!".
- Слабкий: "Та ні, кілька секунд всього".
Як так? Слабші слухали рекомендації уважно. Сильні слухати такий дріб'язок не стали, глянули мануали, побачили метод count() чи аналогічний. Вирішили, що треба унікальний список слів -- зробили копію контейнер із вхідними даними, посортували (знаючи, що таке в стандартній бібліотеці є), видалили дублікати (і таке знайшли), тоді пройшлися по отриманому, кожен раз викликаючи count() для вихідного контейнера. Результат отримано. Разом із, мінімум, квадратичною складність по кількості слів та немалою константою. (Формально -- N*M, де N -- кількість слів, M -- кількість унікальних слів, але на практиці, якщо не відкидати розділові знаки, не приводити до одного регістру, відношення N до M потрапляло десь між 3 і 10). Якщо результат додавали не в std::unordere_map а до std::map, ("бо map вимагав викладач") -- N*M*ln M. Замість менше, ніж N*ln N.
Варіацій подібного виконання бувало багато. Вище -- історія про людей, які прийшли в Python чи С++ з С. Хто із мов з розвиненішою стандартною бібліотекою -- візьме std::set чи іншу реалізацію множини для створення унікального списку. Хтось зразу вектор пар стрічка-кількість створить, з нього вже в std::map копіюючи і т.д. і т.п. Але суті це не змінює.
В результаті -- легкодоступність інструментів разом із нерозумінням, як вони функціонують, народжує монстрів. Якби автори мусили реалізовувати count() на, скажімо, С -- вони б точно задумалися. А так -- чорна скринька і все.Ця історія про нерозуміння функціонування готових засобів. Про важливість розуміти будову CPU чи CUDA-сумісного GPU можна показати на прикладі матричних операцій -- як результат змінюється між тривіальною та все більш оптимальними (читай -- важкозрозумілими) реалізаціями. Порядок-другий різниці -- достатньо переконливо. Однак, для таких задач, через складність, часто, розумним рішенням є скористатися готовим кодом, який нюанси "заліза" враховує -- бібліотекою матриць, перетворень Фур'є, нейромереж тощо.
Чи є щось між тими полюсами? Студенти вкотре показали -- є.
"Біти-байти, навіщо це все?.. Я хочу думати про алгоритм!"
Весна 2020-го. Наближається сесія. Студенти здають практичні. Перевіряю одну із них -- про маніпуляцію із double/float стандарту IEEE 754 вручну. Зроблена досить якісно і з розумінням. Але, коли вчитуюся в подробиці реалізації, натрапляю на epic fail. Потрібно було авторам перевіряти, чи нульовий біт у байті рівний нулю. Реалізували вони це так (С++17):
1 2 3 4 5 6 7 8 9 10 | std::string to_bin(std::byte current_byte) noexcept { return std::bitset<8>(std::to_integer<size_t>(current_byte)).to_string(); } bool check0_1(std::byte cb) noexcept { auto byte_bin = to_bin(cb); return byte_bin[0] == '0'; } |
Замість:
1 2 3 4 | bool check0_2(std::byte cb) { return std::to_integer<size_t>(cb) & 1; } |
Розумію, поспіх. Розумію -- втома, немає часу шукати хороші рішення -- але ж замість очевидної арифметики (яку вони точно знали! -- суджу за попереднім їх "профайлом", та й саму практичну важко зробити, не розуміючи маніпуляцій бітами) -- купа коду. Його ж ще написати треба було. Коду, який перетворює число на стрічку -- із потенційним динамічним виділенням та звільненням пам'яті (якщо small string optimization не було), щоб потім символ в отриманій стрічці порівняти із символом '0' чи '1', а стрічку викинути. Я навіть розгубився -- сходу не зміг придумати, як зробити це ще гірше. (Потім придумав).
Що їм за це було -- хай залишиться за кадром. А я хотів би показати, як, за допомогою виключно онлайн інструментів, перевірити, наскільки все погано із таким кодом.
Перший інструмент -- "Compiler explorer", https://godbolt.org. Скомпілюємо ним цей код. Посилання: https://godbolt.org/z/v7Mncr. Якщо компілювати gcc 10.1 із -O3 та -std=c++17, то check0_2() виглядатиме так:
check0_2(std::byte): mov eax, edi and eax, 1 ret
Три команди. Врахуйте, що у випадку, коли ця функція викликатиметься в коді, ймовірно її буде заінлайнено і вона зведеться до однієї машинної команди and.
Для функції check0_1(), разом із to_bin() -- все настільки жахливо, що я її сховав. Асемблерний лістинг на 155 рядків.
Показати
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | to_bin[abi:cxx11](std::byte): push r12 mov r8d, 48 mov ecx, 8 xor edx, edx push rbp lea rbp, [rdi+16] mov r12, rdi push rbx mov ebx, esi xor esi, esi mov QWORD PTR [rdi], rbp mov QWORD PTR [rdi+8], 0 mov BYTE PTR [rdi+16], 0 call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace_aux(unsigned long, unsigned long, unsigned long, char) test bl, bl jns .L2 mov rax, QWORD PTR [r12] mov BYTE PTR [rax], 49 .L2: test bl, 64 je .L3 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+1], 49 .L3: test bl, 32 je .L4 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+2], 49 .L4: test bl, 16 je .L5 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+3], 49 .L5: test bl, 8 je .L6 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+4], 49 .L6: test bl, 4 je .L7 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+5], 49 .L7: test bl, 2 je .L8 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+6], 49 .L8: and ebx, 1 je .L1 mov rax, QWORD PTR [r12] mov BYTE PTR [rax+7], 49 .L1: mov rax, r12 pop rbx pop rbp pop r12 ret jmp .L11 to_bin[abi:cxx11](std::byte) [clone .cold]: .L11: mov rdi, QWORD PTR [r12] cmp rbp, rdi je .L12 mov rsi, QWORD PTR [r12+16] add rsi, 1 call operator delete(void*, unsigned long) .L12: call std::terminate() check0_1(std::byte): push r12 mov r8d, 48 mov ecx, 8 xor edx, edx push rbp xor esi, esi push rbx mov ebx, edi sub rsp, 32 lea rbp, [rsp+16] mov rdi, rsp mov BYTE PTR [rsp+16], 0 mov QWORD PTR [rsp], rbp mov QWORD PTR [rsp+8], 0 call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace_aux(unsigned long, unsigned long, unsigned long, char) mov rdi, QWORD PTR [rsp] test bl, bl jns .L35 mov BYTE PTR [rdi], 49 mov rdi, QWORD PTR [rsp] .L35: test bl, 64 je .L36 mov BYTE PTR [rdi+1], 49 mov rdi, QWORD PTR [rsp] .L36: test bl, 32 je .L37 mov BYTE PTR [rdi+2], 49 mov rdi, QWORD PTR [rsp] .L37: test bl, 16 je .L38 mov BYTE PTR [rdi+3], 49 mov rdi, QWORD PTR [rsp] .L38: test bl, 8 je .L39 mov BYTE PTR [rdi+4], 49 mov rdi, QWORD PTR [rsp] .L39: test bl, 4 je .L40 mov BYTE PTR [rdi+5], 49 mov rdi, QWORD PTR [rsp] .L40: test bl, 2 je .L41 mov BYTE PTR [rdi+6], 49 mov rdi, QWORD PTR [rsp] .L41: and ebx, 1 jne .L42 .L45: cmp BYTE PTR [rdi], 48 sete r12b cmp rdi, rbp je .L34 mov rax, QWORD PTR [rsp+16] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L34: add rsp, 32 mov eax, r12d pop rbx pop rbp pop r12 ret .L42: mov BYTE PTR [rdi+7], 49 mov rdi, QWORD PTR [rsp] jmp .L45 jmp .L46 check0_1(std::byte) [clone .cold]: .L46: mov rdi, QWORD PTR [rsp] cmp rdi, rbp je .L47 mov rax, QWORD PTR [rsp+16] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L47: call std::terminate() |
Трішки "зоології" -- варіюємо параметри:
- Якщо заборонити виключення, ключем -fno-exceptions, залишиться 133 рядків. Але для check0_2() компілятор і без того побачив, що турбуватися про виключення не потрібно.
- З -O1 пара check0_1()/to_bin() вкладатиметься в 66 рядків асемблерного лістингу, а з -O1 -fno-exceptions -- взагалі 61. Правда, при цьому, викликає зовнішні функції:
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace_aux(unsigned long, unsigned long, unsigned long, char)
і
call operator delete(void*, unsigned long).
check0_2() не змінилася. - Якщо оптимізацію вимкнути повністю, check0_2(), разом із to_integer(), яка буде присутньою в коді явно, міститиме 14+8 = 22 рядки лістингу, а check0_1() із всіма службовими функціями -- 248, плюс зовнішні виклики.
Безпосереднім критерієм ефективності є час роботи коду. Замість того, щоб організовувати виміри часу локально, в коді, чи за допомогою perf/VTune/<ваш улюблений profiler>, скористаємося іншим онлайн сервісом -- Quick C++ Benchmark.
Увага! Benchmarking -- річ складна та нетривіальна. Будьте уважні та недовірливі. Зокрема -- звіряйтеся, що саме ви міряєте. Для того варто в Quick C++ Benchmark ставити галочку біля "Record disassembly" і уважно аналізувати -- а що за (машинний) код ви досліджували. Google benchmark ніби турбується, щоб компілятор зайве не наоптимізував, але на телепатію та чудеса він не здатен.
Код для тестування
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | #include <string> #include <bitset> #include <cstddef> std::string to_bin(std::byte current_byte) noexcept { return std::bitset<8>(std::to_integer<size_t>(current_byte)).to_string(); } bool check0_1(std::byte cb) noexcept { auto byte_bin = to_bin(cb); return byte_bin[0] == '0'; } bool check0_2(std::byte cb) { return std::to_integer<size_t>(cb) & 1; } static void bench_check0_1(benchmark::State& state) { // Code inside this loop is measured repeatedly for (auto _ : state) { bool r = false; for(int i = 0; i<2560; ++i) r |= check0_1(static_cast<std::byte>(i)); // Make sure the variable is not optimized away by compiler benchmark::DoNotOptimize(r); } } // Register the function as a benchmark BENCHMARK(bench_check0_1); static void bench_check0_2(benchmark::State& state) { // Code inside this loop is measured repeatedly for (auto _ : state) { bool r = false; for(int i = 0; i<2560; ++i) r |= check0_2(static_cast<std::byte>(i)); // Make sure the variable is not optimized away by compiler benchmark::DoNotOptimize(r); } } // Register the function as a benchmark BENCHMARK(bench_check0_2); |
Що ж показують тести? Що, для -O3, код із check0_2 швидший в 22-28 раз.
Час виконання кожного із тестів, gcc 10.1, C++17, -O3. |
Враховуючи, що вона справді інлайниться, і на одну її команду and, в асемблерному коді буде ще 5-6 рядків коду циклу, можемо множити цю величину ще на 3-5 -- бо інструкції циклу складають мізерну відносну частку від коду check0_1().
Час виконання частини команд для check0_1() -- видно, що він між ними "розмазаний" менш-більш рівномірно, тому тих же 5 інструкцій циклу вносять помітно менший відносний вклад. |
У цьому коді, щоб не дати компілятору викинути виклик нашої функції, на додачу до benchmark::DoNotOptimize(r), я протягую значення r із використанням OR -- "r |= <...>". Можна також оголосити r як volatile -- результат буде приблизно тим же. Якщо ж написати так:
1 2 3 4 5 6 7 8 | static void bench_check0_2(benchmark::State& state) { // Code inside this loop is measured repeatedly for (auto _ : state) { for(int i = 0; i<2560; ++i) benchmark::DoNotOptimize( check0_2(static_cast<std::byte>(i)) ); } } |
То розрив буде близько 34 і доволі відтворюваним. Якщо глянути на асемблерний код -- зникла одна інструкція, or, тому величина прискорення check0_2() виглядає природною, а check0_1() вже нічого не допоможе.
Для виконання повторного заміру поставте галочку "Clear cached results", але майте повагу до чужого волонтерського проекту -- не "покладіть" його DDoS-ом.
Час виконання кожного із тестів, варіант із benchmark::DoNotOptimize( check0_?(static_cast<std::byte>(i)) ). gcc 10.1, C++17, -O3. |
Цикл по різним значенням аргументу використано, оскільки час роботи to_string() може залежати від кількості не нульових біт (без ретельних тестів, різниця в 23 рази для check0_1(0x00), 27 для check0_1(0xFF)).
На завершення, обіцяне застереження. Запускаючи вперше, я використав такий відверто невдалий для бенчмаркінгу код:
1 2 3 4 5 6 7 8 9 10 | static void bench_check0_2(benchmark::State& state) { // Code inside this loop is measured repeatedly for (auto _ : state) { bool r; for(int i = 0; i<2560; ++i) r = check0_2(static_cast<std::byte>(i)); // Make sure the variable is not optimized away by compiler benchmark::DoNotOptimize(r); } } |
Отримав різницю в 87 тисяч раз. Причина проста -- компілятор викинув зайве з його точки зору.
До чого можна домірятися. |
Знову ж -- інструменти потрібно розуміти, будь-які абстракції течуть.
Підсумок
- Якщо зовсім не розуміти свою машину, буде весело -- і постійно чудеса та сюрпризи.
- Сучасні онлайн-інструменти -- класна штука.
Дякую за статтю. Особливо цікаво було дізнатись про використання онлайн інструментів для оптимізації.
ВідповістиВидалити