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

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

Підписатись на RSS · 📢 Канал в Telegram @stendap_sogodni

13.09.2023

Профілювання С-коду всередині Ruby

Докопався до правди по вчорашньому питанню. Для цього треба було знайти, як профілювати не Ruby-код, а C-код, який складає методи, які ми порівнюємо.

Вдалося це зробити за допомогою цієї статті. Потрібно встановити інструменти профілювання GPerfTools - вони є у Homebrew. А потім скомпілювати Ruby з додаванням профілювання. На моєму Macbook M2 для цього достатньо увімкнути дві опції:

env RUBY_CFLAGS='-fno-omit-frame-pointer' LIBS="-lprofiler" asdf install ruby 3.2.1

# Потім:
env CPUPROFILE="out.profile" ruby payload.rb
pprof $(asdf which ruby) out.profile --svg > out.svg

Результати можна побачити на ілюстрації зверху. Обидві реалізації витрачають більшість часу у виділенні памʼяті. (Решта — це пошук регулярного виразу, який відбувається практично однаково.) Але там, де StringScanner просто бере довжину збігу та копіює від початку, String#scan викликає код, що створює обʼєкти MatchData, а з них вже отримує розташування та довжину збігів. Та, профілювання ясно показує, що саме виділення памʼяті для MatchData становить додаткові витрати часу, які ми бачили вчора.

Чому String#scan потрібні обʼєкти MatchData? Тому, що цей метод шукає фрагменти не тільки на початку, а й в будь-якому місці рядка. До того ж він підтримує регулярні вирази з підгрупами. А StringScanner розрахований на єдиний випадок використання. Маємо висновок: у програмуванні спеціалізація коду веде до його оптимізації.


12.09.2023

Використання StringScanner для прискорення парсера на Ruby

Вже писав, як я прискорив бібліотеку для Ruby css_parser прибиранням регулярних виразів. Проте це було не єдине покращення того сезону. Нещодавня стаття Аарона Патерсона про клас StringScanner нагадала, що я теж залучав StringScanner, щоб прискорити парсер на 80%.

Зміни можна подивитись у цьому коміті. Як бачиш, вони мінімальні: просто замість методу String#scan використовую клас StringScanner. Цей клас існує ще з Ruby 1.8 - саме час його вивчити. :)

Бо мій канал не для того, щоб просто сказати, який клас хороший. Розберімося, чому. Перший ключ в тому, що він цілком написаний на C. Проте так само і String#scan. Взагалі в Ruby багато низькорівневих методів реалізовані на C, що трохи розвіює міф про те, що Ruby обовʼязково має бути повільніше за мови, що компілюються.

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

Я здогадуюсь, що різниця у виділенні памʼяті. Якщо пропустити мій бенчмарк через benchmark-memory, то виявиться, що String#scan виділяє в 5 разів більше памʼяті. Здається (без подальшого профілювання), що String#scan (тобто rb_str_scan) містить більш узагальнений код, а StringScanner#scan (тобто strscan_scan) - зоптимізований рівно під повертання результату з початкової позиції. (Так, до речі, є різниця в тому, що StringScanner шукає тільки на початку рядка — але зазвичай це саме те, що нам потрібно.)

Висновок тут, як і в попередній історії, такий, що виділення памʼяті всередині циклу може бути вузьким місцем та впливати не тільки на витрати ОЗУ, але також й на швидкість виконання.


11.09.2023

Інтуїція про бази даних. Чому OpenSearch агрегує дані швидше, ніж Redshift?

Отримав сьогодні таке питання — напівжартома — бо дійсно, те, що в Redshift займає десять секунд, в OpenSearch може бути виконано миттєво. Йдеться про агрегаційний запит на кшталт “підрахувати статистику суми поля по часовій шкалі.” Думаю, дуже важливо інтуїтивно розуміти, в чому саме різниця. Я вже писав про архітектуру OpenSearch/ElasticSearch, але що по цьому конкретному випадку?

Моя перша здогадка була — що OpenSearch тримає ці дані в памʼяті, а Redshift ні. Проте при перевірці виявилось, що це не зовсім так. Насправді OpenSearch не завантажує всі індекси в памʼять. Зате індекси на диску зберігаються в такому форматі, який готовий для прямого читання в структури памʼяті. Для того є механізм mmap, який уможливлює швидкий доступ до файлу в памʼяті без зайвого копіювання даних. Про це можна почитати в документації до модуля Store.

Проте mmap використовується, напевно, для читання індексів в усіх базах даних. Справжня причина швидкості OpenSearch не в тому, що індекси розташовані в памʼяті.

Причина, на мою думку, в тому, що Redshift, як реляційна база даних, підтримує незрівнянно ширший спектр операцій. Тому вона має складний конвеєр даних, який не потрібний для простих агрегацій. Почнемо з того, що Redshift компілює кожний запит — тільки це може зайняти від 7 секунд. Це та наступні етапи виконання розраховані на маленьку кількість складних аналітичних запитів, які будуть обʼєднувати великі масиви даних реляційними операціями.

В той час як OpenSearch, хоч теж розрахований на великі обсяги даних та на масивну паралелізацію, але обмежений простими запитами, які працюють на рівні індивідуальних документів. Тож моя остаточна відповідь - OpenSearch швидше, бо простіше ніж Redshift. Такий тут компроміс.


10.09.2023

В що я граю останнім часом

Цієї осені дуже складно обрати між безліччю смачних релізів: вже не можу вирішити між Baldur’s Gate 3, Armored Core 6 та Starfield. Поки граю в інше:


09.09.2023

Typescript обмежує

На мою думку, найстрашніша помилка при переході з JavaScript на TypeScript - це очікувати, що доведеться тільки додати до свого коду типи, а структуру кода та підходи можна не змінювати. Так і зʼявляються десятиповерхові типи, неможливі для розуміння.

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


08.09.2023

Завантаження всіх записів з Кафки

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

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

Натомість потрібно встановити власний критерій зупинки. Простіше за все дивитися на поле Timestamp, яке є у кожного запису. Коли таймстемп перебільшить час початку споживання — значить, пора зупинятись.

Взагалі, корисно знати, що окрім власне ключа та змісту у записів ще є відбиток часу та можуть бути метадані. Ці дані легше (та швидше!) отримати, ніж займатися розбором змісту.

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


07.09.2023

Як типи заміняють тести

Є два різновиди помилок: помилки інтеграції — коли код поєднаний неправильно, та помилки логіки — коли код використаний правильно, але задача була зрозуміла або реалізована невірно.

В ідеалі все, чим має займатись інженер — це реалізація бізнес-логіки. В будь-якому разі на логіку доведеться писати тести. Але незалежно від мови, при цьому ми будемо неправильно розуміти та використати код, тобто робитимемо помилки інтеграції — будемо передавати рядок замість обʼєкту, повертати null де очікується значення, і так далі. Витрати на помилки інтеграції не вирішують бізнес-задачі — для бізнесу вони є марними.

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

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

Звісно, моделі типів не є строго статичними чи динамічними, вони існують на шкалі, тому в кожній мові баланс типів та тестів свій.


06.09.2023

Про те, як я колись переписував код на асемблері

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

Мова йде знову про бітові мапи. Одна з ключових операцій з ними — підрахунок кількості “одиничних” бітів в числі (а точніше, в масиві чисел довжиною в мегабайт або більше.) Окрім очевидного підходу “в циклі зсувати число та перевіряти нижній біт” є набагато більш ефективна формула ваги Гемінга. Але максимально ефективно буде використати інструкцію процесора, яка робить саме те, що нам потрібно: POPCNT.

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

Go підтримує код на асемблері без додаткових засобів. До модуля можна просто додати файл .s з реалізацією однієї або декількох функцій. Звісно, такий файл буде обмежений однією з архітектур, тому можна також написати реалізацію за замовчуванням - на Go.

Не потрібно навіть знати в досконалості структуру цього файлу. Командою objdump можна декомпілювати програму або модуль в код на асемблері. Тобто послідовність дій така: пишемо першу версію функції на Go, компілюємо, декомпілюємо, після чого можна переписати тіло функції, як хочемо. Звісно, розуміння асемблера все одно необхідно — знати регістри, моделі адресації та таке інше.

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

Не знаю, чи буде в житті ще один такий випадок, але принаймні можу в резюме написати, “професійний асемблерист”.


05.09.2023

Вирівнювання структур в Go

На роботі поділилися цікавою статтею. У двох словах: Golang вирівнює кожний елемент структури так, що він розташовується в памʼяті на межі, кратній його розміру (наприклад, int64 - на адресі, що кратна 8 байтам.) Таким чином, якщо в структурі перемішані поля byte та int64, можна витратити набагато більше памʼяті, ніж очікуєш (а byte використовують, щоб заощадити памʼять, чи не так?)

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

Та, мова Go достатньо абстрагована, щоб робити вирівнювання автоматично. Для порівняння, на C структура є буквально відображенням області памʼяті на набір полів, тому ніякого вирівнювання не відбувається та ми вільні власноруч обирати, де розташовується кожне поле — навіть якщо це означає стріляти собі в ногу.

Залишається питання — чому тоді Go не оптимізує порядок полів також для економії пам’яті? Я знайшов відповідь — це просто складно. Є пара обговорень конкретно про це. Поки що навіть з автоматичним вирівнюванням є багато нюансів.

Зате існують напівавтоматичні інструменти: правило fieldalignment для govet - його треба явно увімкнути в golangci-lint. Та оптимізатор gopium. Я спробував fieldalignment на нашому проєкті — він знайшов купу “покращень” - але в більшості випадків я б віддав перевагу осмисленій послідовності полів, а не перемішаній заради оптимізації. Хіба що якщо це структура для внутрішньої бази даних, що зберігатиме тисячі записів.


04.09.2023

Пригоди з живленням через USB-C

Напередодні випуску iPhone 15, який, за всією логікою, буде мати порт USB-C, хотілося б проговорити пару нюансів з використанням USB-C для зарядки. ʼ

Бо в USB-C не тільки безліч протоколів та режимів для даних, але й з живленням теж не все просто. Якщо коротко, то в кабелі USB-C є пара жил, по якій пристрій повідомляє зарядці, яку напругу він очікує.