Канал
У Льоні сьогодні
Нотатки архітектора систем та майстра на всі руки. Що я зробив, що з того вийшло, і що це все значить.
-
1BRC: чому Ruby повільний
Таке просте (не легке!) завдання, як 1 Billion Row Challenge, дає нагоду роздивитися причини повільності мови. Однак
ruby-profприховує їх — бо більшість часу цей скрипт витрачає не в Ruby. Тому взяв в руки профайлер коду C та пішов глибше.Виходить, що переважну більшість часу збігає на виділення (та вивільнення) памʼяті. Виявилося, що в операціях над рядками дуже важко уникнути виділення памʼяті практично на кожному кроці. Там, де в низькорівневій мові можна створити сталий буфер та використовувати його повторно, в Ruby на кожне читання з файлу утворюється новий. Навіть якщо переписати алгоритм з буферизацією — все одно на витягання кожного елемента потрібні нові рядки.
Є один очевидний спосіб уникнути цього: обробляти рядок на місці байт за байтом. Тут зʼявляється друга причина — цикли в Ruby мають ненульову ціну. Робити ітерацію на кожний рядок — нормально. А на кожний байт — тобто на порядок більше ітерацій — вже стає найдорожчою частиною програми.
Нарешті, я знайшов як працювати з рядками без виділення памʼяті. А саме: операцією String#byteslice, яка копіює байти з рядка в рядок. Якщо рядок-отримувач має достатньо місткості, памʼять виділятися не буде. (Тут варто знати, що в рядків в Ruby є довжина, а є ще місткість, тобто розмір вже виділеної памʼяті.)
Додати цю операцію до StringScanner#skip_until та IO#readpartial та отримуємо сканування файлу абсолютно без виділення памʼяті! От тільки код стає дуже плутаним (ось він) та — головне — працює все одно повільніше за вчорашній розвʼязок через кількість необхідних додаткових операцій.
Отже, нічого нового: Ruby непрактично повільний на циклах з мільярдів ітерацій, або там, де потрібні мільярди виділень памʼяті. Якщо проєкт такого потребує — пора перейти на низькорівневу мову.
-
Прискорення 1BRC на Ruby
Ось мій наївний розвʼязок, давайте з ним щось зробимо. (Тільки вивід результатів тут поки примітивний, але на швидкодію він не впливає.)
-
З таким кодом пошук міста відбувається щоразу для кожного поля. Натомість можна один раз знаходити дані для міста, та решту операцій робити вже над ними. Прискорення 14%
-
Ані хеш, ані обʼєкт — не найшвидша структура даних в Ruby. Найшвидшою буде масив. Хеш з містами доведеться залишити, а от дані для кожного міста можна організувати в масив з 4 елементів. Прискорення 12% (А ще — так само швидко буде зі Struct, бо Struct всередині має масив. В реальному коді я б саме Struct і вжив, а в цьому вона заважала профайлеру, тож зробив з масивом.)
-
Операція
splitв топі наших витрат. Вона буде швидше, якщо вказати кількість сегментів, які ми хочемо отримати. В цьому разі.split(';', 2)прискорює на неймовірні 10%. -
Замість явної перевірки на наявність даних в хеші можна створити хеш зі даними за замовчуванням. Дрібниці, але 1% прискорення це дає.
-
Виявилося, що якщо потрібно взяти з масиву всі 4 значення, то швидше це робити з spread operator ніж по одному. Отримуємо ще 1% прискорення.
-
Тепер, беремося за читання з файлу. По-перше, читання в UTF-8 (тобто за замовчуванням) буде уповільнено необхідною перевіркою на багатобайтові символи. Щоб знайти крапки з комою та нові рядки, нам це не потрібно. Перехід до двійкового кодування (або ж
ASCII8) дає дуже приємні 7%. -
Нарешті, взагалі можна позбавитись розбиття рядка, якщо відразу читати окремо назву міста та температуру. Для того в метод
readlineможна передати символ “кінця рядка”, який цілком може бути крапкою з комою. Є тільки один нюанс: насправдіreadlineповертає рядок з кінцевим символом. Поки що нам це не заважало тому, що операціяto_fвідкидає всі зайві символи. А що робити з зайвою крапкою з комою в кінці назви? Обрізати її вийшло надто дорого; тому я залишаю назви з зайвим символом, а обрізаю вже під час виводу. Такий підхід дає ще 9% прискорення. -
Що не дало ніяких результатів: вкладення перевірки на max в перевірку на min; заміна
splitнаrindex; використання цілих чисел замість дробових. Взагалі, всякі нестандартні операції не окупають додаткових витрат — наприклад, на ручний розбір температури в ціле число. До того ж арифметика на дробових та цілих числах в Ruby практично однакова за швидкістю. -
Також нічого не дала явна буферизація файлу: ані з експериментальним класом
IO::Buffer, ані з читанням великого (128Мб!) рядка в памʼять та подальшою обробкою черезStringIO. Роблю висновок, що нормальне читання з файлу вже й так достатньо буферизоване. -
Остаточне покращення: з 10 хвилин до 5 з половиною. Непогано, хоча все ще в 3 рази повільніше ніж наївний розвʼязок на Go. Це повинно продемонструвати, що “Ruby повільна мова” та такі задачі, як 1BRC, краще відразу робити якоюсь іншою. Проте, з іншого боку, навіть таку щільну задачу можна простими способами прискорити у 2 рази — чого на практиці може бути достатньо.
-
-
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.
-
Очікування та як з ним боротись
Аспект роботи, який надзвичайно деморалізує: коли потрібно чогось чекати невідомо скільки. Незручність помножується, якщо до того ще й необхідно періодично робити дії чи просто перевіряти готовність. В таких випадках ніби можна робити паралельно іншу задачу, але на практиці очікування так виснажує психічно, що на багатозадачність мене не вистачає.
Що робити? Делегувати очікування комп’ютера. Шукати спосіб, як автоматично перевіряти готовність; якщо очікування потребує регулярних дій, то їх також автоматизувати. Тоді можна буде дійсно перемикнутися на іншу задачу та чекати сповіщення про готовність.
Наприклад: під час виправлення випадково невдалих тестів доведеться багато разів ті тести запускати, поки не вийде невдача. Подекуди десятки разів. Робити це вручну просто знущання з себе; кожні десять секунд натискати дві кнопки. А можна замість того запустити цикл та вийти з нього, коли зʼявиться помилка:
for i in {0..100}; do run_my_tests || break; doneТривалі хмарні процеси — в першу чергу CI/CD - завжди кращі, коли по завершенню приходить сповіщення. Можна навіть автоматично перезапускати збірку, якщо це має сенс.
А якщо хочеться перейти на індустріальний рівень автоматичного очікування — звертаю увагу на Huginn.
-
Традиційний застосунок — найкращий хобі-проєкт для вебінженера?
Якщо ти заробляєш веброзробкою, то голова генерує ідеї вебсайтів та вебзастосунків. Хочу звернути увагу, що, може, то не найкращий вибір для маленького проєкту, та краще спробувати себе в “традиційних”, тобто “локальних” застосунках. Для iPhone, для Android, для настільних ОС — взагалі, до того, чим користуєшся. Мої аргументи:
-
Можна попрацювати з цікавими мовами та фреймворками, з якими не стикаєшся на роботі. Або - загорнути знайомі вебтехнології. В будь-якому разі, розширити знання та отримати нову перспективу.
-
Застосунок легко розгорнути: для себе взагалі не потрібно, а для знайомих можна через TestFlight (а для macOS/Windows/Linux - взагалі поділитися файлом.)
-
Традиційним застосункам доступні всі можливості компʼютера — від графічної карти до вебкамери — та операційної системи — від віджетів до файлів — та з них можна зробити дійсно особливий досвід. Наприклад, все планую зробити застосунок смуги меню, який буде оповіщувати про тривоги з AWS. (Якщо такий вже є, прошу поділитись, а якщо ні — дарую ідею!)
-
Тому з застосунків виходять чудові проєкти для себе чи для близьких: вони за природою приватніші, при тому не потребують авторизації.
-
Навіть якщо запускати його у широку публіку, то, можливо, вдасться уникнути сервера — а це, можливо, найскладніша та при цьому непомітна та неоцінена частина. Для розробника-одинака це заощадження (як по часу, так і по грошах!) може бути визначальним фактором.
-
-
Ще 10 днів зі стохастичним таймтрекером
З попереднього запису минуло ще десять днів. Тоді в мене були здебільшого технічні враження, а тепер — трохи концептуальних.
По-перше, сам факт, що поки я продовжую заносити записи в трекер. Майже всі! Цьому сприяє легкість занесення — відкрити телефон та вказати пару тегів це справа на десять секунд. Плюс, про цей трекер не потрібно памʼятати — він сам нагадує. Нагадує нечасто, тому не дратує.
По-друге, такий метод таймтрекінгу точно віддає перевагу “великій картині”, а не дрібницям. Щоб тег засвітився в статистиці, він має трапитись багато разів. Тобто це ті активності, які повторюються кожен день. Та вони, абсолютно точно, є ключем до розуміння та покращення витрат власного часу.
Одне діло думати “я надто багато часу сиджу в телефоні”, та зовсім інше — бачити, що сидіння в телефоні у топ-10 активностей за тиждень (топ-1 виходить “сон”, до речі, а топ-2 “сидіння за компʼютером”… теж є над чим подумати.) Причому це більш точний показник ніж функція телефону “екранний час”, бо тег “в телефоні” значить, що я буквально в цей час дивився в телефон… відмазок немає.
Зате з позитивного — я більше часу проводжу з сімʼєю, ніж уявляв. Тут теж, СтохастичнийТаймтрекер здатний виміряти те, що я б ніколи не записував традиційним чином (підійшов до сина — відкрив Toggl - увімкнув таймер… бррр.) Навіть більше, ніж сиджу в телефоні! (Хотілося б, щоб це було очевидно, але такі вони, наші часи.)
А ще стільки всього можна доробити. Схожий метод використовують для журналу емоцій — можна пропонувати окрім тегів, ткнути в коло емоцій. А потім взагалі корелювати теги та емоції! Ще цікавий показник — монотонність тегів — тобто як часто тег повторюється — це вказує на схильність активності до відволікань.
Одним словом, я в захваті, не дочекаюся, коли вже можна буде поділитися цим продуктом з людьми.
-
Особливості Swift Charts
Робота над стохастичним таймтрекером продовжується. Графіки та аналітика — це те, що наділяє такий продукт сенсом, тож я знов багато часу провожу за вивірянням коду графіків.
Swift Charts - трохи незвична для мене бібліотека (а звична то D3 або Highcharts). Вона є повністю декларативною та високорівневою, тому коригування вигляду відбувається високорівневими та абстрактними засобами.
Ну, наприклад: щоб спрямувати шкалу у зворотному напрямку, потрібно… передати їй обʼєкт “області визначення”, та в ньому вказати напрямок. А щоб шкала точно включала значення від 0 до 24, в цю область визначення додаються ці межі:
.chartYScale(domain: .automatic(reversed: true, dataType: Double.self) { domain in domain.append(0) domain.append(24) })Коли вже знаєш розвʼязок. то він ніби логічний. Проте щоб дійти його, доводиться “реверс-інжинірити” власні вимоги з простого візуала у бізнес-модель графіка. (Або, іншими словами, навчитися користуватися цією бібліотекою.)
Окрема задачка — це відображення графіка з розривами. Наприклад, якщо в деякий тиждень тег не був використаний, тобто даних немає — а на сусідні тижні є — то за замовчуванням Swift Charts просто інтерполює проміжок. Щоб того уникнути, я знаю тільки один спосіб: кожний проміжок без розривів має стати окремою серією. Серія є параметром для точки. Тож мусимо вручну визначити, що для нас є розривом, розбити дані на безперервні проміжки, призначити кожному проміжку ключ, та передавати цей ключ параметром серії. Це все виглядає набагато складніше, ніж має бути — проте працює.
-
Любов до таблиць
Маю зізнатись: в мене є слабкість до програмування довжелезних, але рутинних таблиць чи специфікацій. Це така спокусливо медитативна діяльність, що важко буває зупинитись.
Наприклад, потрібно мені було нещодавно залучити модуль golang.org/x/text/encoding для перекладу тексту з одного кодування в інше. Але ось в чому справа — цей модуль немає абстракції, де кодування визначається назвою. Натомість там є низка реалізацій типу
Decoderдля різних кодувань; виходить, що ми маємо наперед знати, яку саме Реалізацію взяти. А я знаю тільки назву кодування.В цілому, нічого страшного — можемо написати довгий
switch, який обирає правильний тип декодера за назвою. Але який перелік назв включити в цейswitch? Хочеться, щоб він був за можливістю вичерпним.…Знаходжу в інтернеті гарний документ, близький до стандартного, з переліком кодувань та їх можливих назв (бо у назв є синоніми: наприклад,
ISO-8859-1таlatin1.) Тут включається любов до таблиць, я забуваю про пріоритети та починаю перекладати документ в код. Весь. Хоча ні — цього разу помітив та зупинився, звідки й пост.В теорії, нічого в цьому поганого немає — якщо ця робота дійсно потрібна. Краще почати все ж з мінімального прикладу, інтегрувати, протестувати, а потім вже робити рутинний переклад. Бо потім виявляється, що табличка була зайва чи не влаштовувала потреби.
Так само мене спокушають переклади коду з мови на мову, інтеграції з API, специфікації форматів файлів… все, де можна відключити мозок та писати код рядок за рядком.
-
Sync та Async: покрокове програмування
Натрапив на цікаве питання: чому ми кажемо синхронний код на той, що виконується послідовно, та асинхронний — якщо рівночасно? Ніби ж навпаки, “синхронно” має значити “в один час”, тобто виклик, наприклад,
Promiseпростою мовою скоріше “синхронний” ніж “асинхронний”..? Спробую пояснити.Може, я переграв у Baldur’s Gate 3, але виконання рівночасних програм нагадує покрокову гру. Програма складається з послідовних блоків, які виконуються по черзі. (Принаймні, абстракція така — що там насправді відбувається, нам невідомо — процесор, ОС, та середовище виконання всі накладають свої нюанси.)
Як часто ти думаєш про час при написанні? Не час як момент історії, а як розклад виконання. Напевно, при синхронному програмуванні — взагалі ніколи. Хоч кожна інструкція неодмінно займає реальний час. Ми пишемо синхронні програми так, ніби час зупинився. І це просто чудово, бо звільняє нас від всіх труднощів синхронізації (наша програма вже синхронна, вона не потребує синхронізації, тобто злагодження в часі.) Тільки асинхронне програмування змушує нас згадати, що час існує — реальний час, з затримками та чергуванням, а не просто послідовність дій.
…Так само в покроковій грі, зміст кроку відбувається послідовно, але без обігу реального часу поза діями поточного гравця, тобто синхронно. Має сенс?
-
Майже порожній аркуш
Широко відома проблема порожнього аркуша в дизайні інтерфейсів — тобто, що робити з застосунком за браком даних, на початку роботи. Проте, поки даних зовсім немає, зазвичай зрозуміло, чим їх замінити. Картинка з прикладом заповнення, відеопосібник, просто текст — за прикладами далеко ходити не треба.
Помітив, що існує й друга, більш підступна проблема. В деяких інтерфейсах одного чи двох початкових записів не вистачає для повноцінної роботи. Наприклад, я зіткнувся з цим у WeightPlot: щоб побудувати графік ваги, потрібно хоч тиждень даних. Але що показувати користувачу весь цей перший тиждень?
Проблема підступна, бо технічно, за рідкісним винятком, інтерфейс буде працювати навіть з одним записом (чого вже там, навіть з нулем записів все зазвичай технічно працює.) Також і тому, що розробка окремого дизайну, який буде видно перший тиждень, виглядає зайвою… Плюс, порожній аркуш — це просто заглушка, а от якщо якісь дані є, то вже доведеться придумати та розробити для них вигляд.
Окрім графиків, майже порожній аркуш впливає на сторінки зі статистикою, взаємовідносинами між записами, всілякими “розумними” інтерпретаціями. Для всього цього треба визначити мінімальний обсяг даних, з яким відображення має сенс. А далі… деколи навіть “зберіть більше даних, щоб побачити цю сторінку” буде краще, ніж нічого.
Я думаю, майже порожній аркуш необхідно врахувати, та навіть в першу чергу, бо саме його побачить новий користувач… і якщо досвід буде поганим, то він залишить продукт, не побачивши всю повноту та красоту наповненого даними інтерфейсу.