Стендап Сьогодні

Що я зробив, що я хочу зробити, і що це все значить.
Повсякденні здобутки в форматі стендапу.
Детальніше в статті

Підписатись на RSS
📢 Канал в Telegram @stendap_sogodni
🦣 @stendap_sogodni@shevtsov.me в Федиверсі

13.03.2024

1BRC на Ruby: нарешті, правильна паралелизація

Так, виявляється, читати з файлу одночасно з декількох місць — значно повільніше, ніж робити це послідовно. А значить, щоб обробити файл паралельно, мусимо побудувати складніший скрипт з пулом потоків та розподілом роботи.

Скрипт буде читати з файлу блок за блоком (тут як раз стає до нагоди те, що я вже зробив поблокову обробку) та віддавати їх потокам, а потім збирати результати. Типова архітектура fan-out. Зробив версії з ракторами та з процесами; різниця в тому, що процеси спілкуються через “труби” IO.pipe, а у ракторів є своя семантика обміну повідомленнями, яка дуже нагадує мені Golang, бо обмін є синхронним (тобто як відправник, так і отримувач блокують до готовності протилежної сторони.) Код тут.

Наступне відкриття: рівночасні програми на Apple Silicon поводяться неочевидно, тобто, не залучають всі ресурси в системі. Так, мені не вдалося використати 100% ядер; з наявних на моєму MacBook Air 4 “швидких” та 4 “економних” ядер, два швидких залишались вільними, незалежно від кількості ракторів. Макбуки надто розумні — про це можна окремий пост. Навіть так, паралельне рішення працювало у 2-3 рази швидше за найкраще послідовне — нарешті, помітне покращення, хоч і далеке від ідеального.

Щоб уникнути еплівських нюансів, переніс тестовий скрипт на Fly.io. Про це теж треба окремий пост, але головне, що на Fly.io паралельна версія з fork реально дала восьмикратний приріст, тобто за кількістю ядер. А з іншого боку, розвʼязки з ракторами просто скидають SEGFAULT - чому я не дуже здивований, бо вони “експериментальні” - хоча могло б бути й краще.

Якщо підсумувати, то так, паралелізувати розвʼязки на Ruby можна, але робити це на процесах (тобто з fork) та добре продумати обіг даних. Або взяти готові рішення на кшталт Sidekiq та не вигадувати велосипеда.


12.03.2024

Паралелізація 1BRC на Ruby: несподівані результати

Останнє, що я хотів спробувати після минулих експериментів, це паралелізувати розвʼязок, проілюструвати, що це набагато простіше, ніж оптимізувати, та закрити питання. Вийшло трохи не так.

Про паралелізацію. Вочевидь, ми повинні розбити файл на декілька розділів та обробляти їх паралельно, а наприкінці обʼєднати результати. Обʼєднати нескладно, але з розбиттям виникає нюанс: потрібно зробити так, щоб кожний розділ починався з нового рядка.

Зробив так: розділяю розмір файлу на рівні частини, а потім на початку кожної частини зчитую до початку наступного рядка те, що насправді є останнім рядком попереднього розділу. Тому підготовка розділів відбувається з кінця на початок. (Та, може, потрібно додати, що для паралельного читання ми відкриваємо файл окремо для кожного розділу.)

Я знаю три способи технічної реалізації: потоки (Thread), рактори (Ractor) та процеси (fork). Очікування були, що потоки будуть трошечки швидше, а рактори та процеси дадуть близьке до кратного пришвидшення.

Вийшло не так. Потоки вийшли у 8 разів повільніше послідовного обчислення, а рактори та процеси — десь у 5 разів повільніше. Оце диво! Невже паралельне читання з файлу настільки обмежує? Бо я інакше взагалі не розумію, як паралельні обчислення, які помітно навантажують процесор, можуть бути настільки повільними. Поки залишу з тим висновком, що паралелізація теж не магічна та може навіть в рази уповільнити код.

Код тут. Відрізняється тільки манера запуску та збору результатів.


11.03.2024

Debug на Ruby з VSCode

Чесно, я останній раз впевнено дебажив ще на Borland Pascal та можливо ще C++ у Visual Studio (звичайному, не Code, а версії десь так 2010). Тоді програма була програмою: не було ані тестів, ані вебсерверів, ані “живого перезавантаження”. Потім в моєму житті трапився веб, а дебаг став рідкісним.

Ось, сьогодні вирішив замість звичного розсипання по коду puts, зʼясувати, чи можна у 2024 дебажити Ruby з VSCode. Виявилося, що можна. Та й не дуже складно.

Нам потрібно буде доповнення VSCode rdbg Ruby Debugger. Воно працює разом з офіційним гемом debug, проте цей гем достатньо мати встановленим — включати в проєкт його не потрібно.

У цього доповнення є два режими роботи. Той, де воно запускає програму для дебагу, в мене не запускався — здається, через те, що мій термінал fish. Інший режим — підключення до програми — спрацював. Його й рекомендую, бо, як на мене, такий підхід більш прозорий.

Отже, спочатку маємо запустити програму. Для того є команда rdbg.

rdbg -O -c bundle exec rspec # ... і так далі

Важливо, що так можна запустити будь-яку програму на Ruby, хоч RSpec, хоч Rails, хоч просто власний скрипт. Я думаю, RSpec то найкращий вибір, бо там і контекст готовий, і легко запустити той модуль, який потрібний, в ізоляції.

Точки зупинки можна створювати засобами редактору, нічого в коді писати не треба. Потім стартуємо програму, запускаємо доповнення в режимі attach, та насолоджуємося. Покрокове виконання, стеження за значеннями (тобто виконання довільних виразів в поточному контексті), точки зупинки з умовами (теж довільними виразами на Ruby)… Працює бездоганно!


10.03.2024

Доповнення до класів у Swift

Коли шукаєш розвʼязки всіляких дрібниць на Swift, можна швидко натрапити на конструкцію extension. Доповнення це завидна можливість мови, яка дозволяє прозоро та безпечно розширити функціональність класів, які вже існують, включаючи такі, що створені не нами. Точніше, саме для чужих класів доповнення й існують.

Ось, наприклад, доповнення, яке додає до символу функцію isEmoji. Так, далі можна писати "😀".isSingleEmoji", що дуже зручно.

Рубісти відразу впізнають такий спосіб розробки. В нас цілий Active Support так побудований — одна з фундаментальних частин сучасного Ruby.

Проте міксини в Ruby та доповнення у Swift мають одну принципову різницю. А саме, доповнення не впливають на внутрішню роботу класу. Ми не маємо доступу до приватних частин класу, та можемо тільки доповнювати його функціями, яких не було до того. Новий код буде обовʼязково навколо, а не всередині старого.

Технічно, замість доповнення можна було просто створити новий модуль (EmojiDetector.isEmoji(character:)). Тільки, по-перше, писати більше, а по-друге, завдяки доповненням можна ще й реалізувати інтерфейси, тобто підігнати готовий клас під нову потребу.


09.03.2024

1BRC: чому Ruby повільний

Таке просте (не легке!) завдання, як 1 Billion Row Challenge, дає нагоду роздивитися причини повільності мови. Однак ruby-prof приховує їх — бо більшість часу цей скрипт витрачає не в Ruby. Тому взяв в руки профайлер коду C та пішов глибше.

Виходить, що переважну більшість часу збігає на виділення (та вивільнення) памʼяті. Виявилося, що в операціях над рядками дуже важко уникнути виділення памʼяті практично на кожному кроці. Там, де в низькорівневій мові можна створити сталий буфер та використовувати його повторно, в Ruby на кожне читання з файлу утворюється новий. Навіть якщо переписати алгоритм з буферизацією — все одно на витягання кожного елемента потрібні нові рядки.

Є один очевидний спосіб уникнути цього: обробляти рядок на місці байт за байтом. Тут зʼявляється друга причина — цикли в Ruby мають ненульову ціну. Робити ітерацію на кожний рядок — нормально. А на кожний байт — тобто на порядок більше ітерацій — вже стає найдорожчою частиною програми.

Нарешті, я знайшов як працювати з рядками без виділення памʼяті. А саме: операцією String#byteslice, яка копіює байти з рядка в рядок. Якщо рядок-отримувач має достатньо місткості, памʼять виділятися не буде. (Тут варто знати, що в рядків в Ruby є довжина, а є ще місткість, тобто розмір вже виділеної памʼяті.)

Додати цю операцію до StringScanner#skip_until та IO#readpartial та отримуємо сканування файлу абсолютно без виділення памʼяті! От тільки код стає дуже плутаним (ось він) та — головне — працює все одно повільніше за вчорашній розвʼязок через кількість необхідних додаткових операцій.

Отже, нічого нового: Ruby непрактично повільний на циклах з мільярдів ітерацій, або там, де потрібні мільярди виділень памʼяті. Якщо проєкт такого потребує — пора перейти на низькорівневу мову.


08.03.2024

Прискорення 1BRC на Ruby

Ось мій наївний розвʼязок, давайте з ним щось зробимо. (Тільки вивід результатів тут поки примітивний, але на швидкодію він не впливає.)

  1. З таким кодом пошук міста відбувається щоразу для кожного поля. Натомість можна один раз знаходити дані для міста, та решту операцій робити вже над ними. Прискорення 14%

  2. Ані хеш, ані обʼєкт — не найшвидша структура даних в Ruby. Найшвидшою буде масив. Хеш з містами доведеться залишити, а от дані для кожного міста можна організувати в масив з 4 елементів. Прискорення 12% (А ще — так само швидко буде зі Struct, бо Struct всередині має масив. В реальному коді я б саме Struct і вжив, а в цьому вона заважала профайлеру, тож зробив з масивом.)

  3. Операція split в топі наших витрат. Вона буде швидше, якщо вказати кількість сегментів, які ми хочемо отримати. В цьому разі .split(';', 2) прискорює на неймовірні 10%.

  4. Замість явної перевірки на наявність даних в хеші можна створити хеш зі даними за замовчуванням. Дрібниці, але 1% прискорення це дає.

  5. Виявилося, що якщо потрібно взяти з масиву всі 4 значення, то швидше це робити з spread operator ніж по одному. Отримуємо ще 1% прискорення.

  6. Тепер, беремося за читання з файлу. По-перше, читання в UTF-8 (тобто за замовчуванням) буде уповільнено необхідною перевіркою на багатобайтові символи. Щоб знайти крапки з комою та нові рядки, нам це не потрібно. Перехід до двійкового кодування (або ж ASCII8) дає дуже приємні 7%.

  7. Нарешті, взагалі можна позбавитись розбиття рядка, якщо відразу читати окремо назву міста та температуру. Для того в метод readline можна передати символ “кінця рядка”, який цілком може бути крапкою з комою. Є тільки один нюанс: насправді readline повертає рядок з кінцевим символом. Поки що нам це не заважало тому, що операція to_f відкидає всі зайві символи. А що робити з зайвою крапкою з комою в кінці назви? Обрізати її вийшло надто дорого; тому я залишаю назви з зайвим символом, а обрізаю вже під час виводу. Такий підхід дає ще 9% прискорення.

  8. Що не дало ніяких результатів: вкладення перевірки на max в перевірку на min; заміна split на rindex; використання цілих чисел замість дробових. Взагалі, всякі нестандартні операції не окупають додаткових витрат — наприклад, на ручний розбір температури в ціле число. До того ж арифметика на дробових та цілих числах в Ruby практично однакова за швидкістю.

  9. Також нічого не дала явна буферизація файлу: ані з експериментальним класом IO::Buffer, ані з читанням великого (128Мб!) рядка в памʼять та подальшою обробкою через StringIO. Роблю висновок, що нормальне читання з файлу вже й так достатньо буферизоване.

  10. Остаточне покращення: з 10 хвилин до 5 з половиною. Непогано, хоча все ще в 3 рази повільніше ніж наївний розвʼязок на Go. Це повинно продемонструвати, що “Ruby повільна мова” та такі задачі, як 1BRC, краще відразу робити якоюсь іншою. Проте, з іншого боку, навіть таку щільну задачу можна простими способами прискорити у 2 рази — чого на практиці може бути достатньо.


07.03.2024

1 Billion Row Challenge... на Ruby?

До моєї уваги потрапив 1 Billion Row Challenge - нещодавнє мінізмагання з програмування, де потрібно якнайшвидше агрегувати мільярд рядків з інформацією. Хоча офіційно розвʼязок повинен бути на Java, проте вже зʼявилися й спроби на Go, Rust і так далі. Але на Ruby нічого немає. Мені стало цікаво — чому так та що з цього може вийти?

Почав з того, щоб зрозуміти межі можливого. Взяв версію на Go з цієї статті (бо Go мені більш зрозуміло ніж Java.) На моїй машині найшвидша версія без паралелізму займає близько 30 секунд.

Потім зробив тривіальну версію на Ruby (бо над розвʼязком задачі взагалі думати не треба — складність в оптимізації.) Вийшло 586 секунд. А тривіальний розвʼязок на Go - 105 секунд. Цифри хоч би однакового порядку, тобто можна й погратись. Можна обчислити, що гарним результатом для оптимізованого розвʼязку на Ruby буде десь 160 секунд.

Тепер — абсолютний мінімум. Просто підрахувати рядки в цьому файлі на Ruby займає 80 секунд. Я пішов ще далі: просто порахувати до мільярда - 23 секунди.

Моєю першою ідеєю було — замість ітерування рядками зробити такий собі побайтовий процесор зі скінченним автоматом. Мене чекала несподіванка — читання файлу байтами, а не рядками триває набагато довше - 350 секунд! (Може, ця ідея і вкотить на Go, потім спробую.)

Інтуїція, що читання байтами “простіше” та тому швидше, не спрацювала ось чому: в Ruby читання є функцією на С, причому досить складною, з купою перевірок. Робити їх на кожний байт дуже дорого.

Щоб поки зупинитись, зазначу: будь-який ефективний розвʼязок на Ruby буде використовувати більш високорівневі абстракції, де логіки менше в Ruby та більше в C. Можливо, StringScanner.


06.03.2024

Очікування та як з ним боротись

Аспект роботи, який надзвичайно деморалізує: коли потрібно чогось чекати невідомо скільки. Незручність помножується, якщо до того ще й необхідно періодично робити дії чи просто перевіряти готовність. В таких випадках ніби можна робити паралельно іншу задачу, але на практиці очікування так виснажує психічно, що на багатозадачність мене не вистачає.

Що робити? Делегувати очікування комп’ютера. Шукати спосіб, як автоматично перевіряти готовність; якщо очікування потребує регулярних дій, то їх також автоматизувати. Тоді можна буде дійсно перемикнутися на іншу задачу та чекати сповіщення про готовність.

Наприклад: під час виправлення випадково невдалих тестів доведеться багато разів ті тести запускати, поки не вийде невдача. Подекуди десятки разів. Робити це вручну просто знущання з себе; кожні десять секунд натискати дві кнопки. А можна замість того запустити цикл та вийти з нього, коли зʼявиться помилка:

for i in {0..100}; do run_my_tests || break; done

Тривалі хмарні процеси — в першу чергу CI/CD - завжди кращі, коли по завершенню приходить сповіщення. Можна навіть автоматично перезапускати збірку, якщо це має сенс.

А якщо хочеться перейти на індустріальний рівень автоматичного очікування — звертаю увагу на Huginn.


05.03.2024

Традиційний застосунок — найкращий хобі-проєкт для вебінженера?

Якщо ти заробляєш веброзробкою, то голова генерує ідеї вебсайтів та вебзастосунків. Хочу звернути увагу, що, може, то не найкращий вибір для маленького проєкту, та краще спробувати себе в “традиційних”, тобто “локальних” застосунках. Для iPhone, для Android, для настільних ОС — взагалі, до того, чим користуєшся. Мої аргументи:


04.03.2024

Ще 10 днів зі стохастичним таймтрекером

З попереднього запису минуло ще десять днів. Тоді в мене були здебільшого технічні враження, а тепер — трохи концептуальних.

По-перше, сам факт, що поки я продовжую заносити записи в трекер. Майже всі! Цьому сприяє легкість занесення — відкрити телефон та вказати пару тегів це справа на десять секунд. Плюс, про цей трекер не потрібно памʼятати — він сам нагадує. Нагадує нечасто, тому не дратує.

По-друге, такий метод таймтрекінгу точно віддає перевагу “великій картині”, а не дрібницям. Щоб тег засвітився в статистиці, він має трапитись багато разів. Тобто це ті активності, які повторюються кожен день. Та вони, абсолютно точно, є ключем до розуміння та покращення витрат власного часу.

Одне діло думати “я надто багато часу сиджу в телефоні”, та зовсім інше — бачити, що сидіння в телефоні у топ-10 активностей за тиждень (топ-1 виходить “сон”, до речі, а топ-2 “сидіння за компʼютером”… теж є над чим подумати.) Причому це більш точний показник ніж функція телефону “екранний час”, бо тег “в телефоні” значить, що я буквально в цей час дивився в телефон… відмазок немає.

Зате з позитивного — я більше часу проводжу з сімʼєю, ніж уявляв. Тут теж, СтохастичнийТаймтрекер здатний виміряти те, що я б ніколи не записував традиційним чином (підійшов до сина — відкрив Toggl - увімкнув таймер… бррр.) Навіть більше, ніж сиджу в телефоні! (Хотілося б, щоб це було очевидно, але такі вони, наші часи.)

А ще стільки всього можна доробити. Схожий метод використовують для журналу емоцій — можна пропонувати окрім тегів, ткнути в коло емоцій. А потім взагалі корелювати теги та емоції! Ще цікавий показник — монотонність тегів — тобто як часто тег повторюється — це вказує на схильність активності до відволікань.

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