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

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

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

20.08.2024

Низькорівневі мови та навіщо вони потрібні

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

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

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

Як простий приклад: виклик функції “так, як це задумано” покладається на аргументи відомого розміру. А динамічна типізація не дозволяє нам знати розмір аргументу заздалегідь, Тому мова робить додаткові, непомітні нам дії, щоб викликати кожну функцію. Та зараз динамічні мови всі впроваджують JIT - тобто відокремлюють випадки, де аргументи відомі, та роблять для них “простий” виклик.

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

Та, навіть коли твоя головна мова — високорівнева, варто знати деяку низькорівневу для тих випадків, коли потрібно витиснути з системи все. А також, можливо, щоб розуміти, як воно все працює всередині. Чи буде це С, С++, Rust, Go чи D - вже менш важливо.


19.08.2024

XcodeGen: генератор для проєктів XCode

Засоби розробки для платформ Apple знаходяться десь посередині драбини відкритості. З одного боку, вони доступні для автоматизації та використовують текстові файли конфігурації. З іншого, для користувача продумана тільки верхня обгортка.

Наприклад, головний файл проєкту project.pbxproj є текстовим, але редагують його тільки через графічний інтерфейс XCode. Причому зміни не обовʼязково утворюють зрозумілу історію. Та й побачити “що ми там наклацали” складно. Плюс мене зокрема дратувало, що XCode не використовує нормальну ієрархію файлів та каталогів, а має власну, внутрішню.

Тому приємно було дізнатися з сусіднього блогу про існування утиліти XcodeGen, яка просто бере та генерує всю конфігурацію проєкту з “рукописного” YAML. А файлову структуру зчитує з диска. Таке я люблю!

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


18.08.2024

Словники в Go: безплатних сніданків не існує

В продовження історії з модулем unique для Go 1.23, хотів на практиці перевірити різницю між використанням символу та рядка.

Вимірював витрати памʼяті map[unique.Handle[string]]int проти map[string]int. Щоб ключ повторювався, наробив JSON з мільйоном обʼєктів {"foo": 123}. Дійсно побачив різницю: 204 Мб проти 289 Мб… тобто 85 байтів на економії на елемент. По-перше, 85 байтів це якось незрівнянно багато. По друге… чому взагалі той масив займає в памʼяті 200 Мб? Це у два рази більше, ніж текст JSON.

Почав дивитися, як зміняться витрати, якщо зробити більш ніж один ключ. А ніяк! Виявилося, що Go резервує в новому словнику місце під 8 записів. Тоді вже ясніше: насправді надлишок на ключ-рядок проти ключа-вказівника лише 11 байтів: вказівник займає в памʼяті 8 байтів, а рядок - 16 байтів, плюс 3 байти на сам зміст. (Тобто короткі рядки теж неочікувано неефективні.)

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

Звісно, в Go можна зробити набагато краще: оголосити структуру. Скільки займає struct { Foo int }? 8 байтів. Але я не завжди звертав увагу на місця, де словники можна було замінити структурами. Особливо важко, коли паралельно програмуєш на мові, де між ними практично немає різниці. Таке.


17.08.2024

Модуль unique в Golang 1.23: глобально-унікальні символи

Не встиг я подати ідею про заміну повторюваних рядків на глобально-унікальні символи (interning), як вийшов Go 1.23, де зʼявився модуль unique, який буквально те й робить. Приємна несподіванка!

Метод unique.Make підтримує не тільки рядки, а й будь-який порівнюваний тип, та замінює значення на глобально унікальні вказівники. Рядки-літерали в Go і так є константами, хіба що кожна копія має власний 16-байтовий заголовок. А от якщо доводиться отримувати багато повторюваних рядків ззовні, модуль unique стане до нагоди.

Наприклад, ми завантажуємо з бази фічафлаґи для поточного користувача. Кожний виклик утворює рядки наново та залишає за собою купу сміття. Якщо зробити їх типом unique.Handle[string], то ловимо двох зайців: фічафлаґи займають менше памʼяті, але до того ж тепер їхнє порівняння зводиться до порівняння вказівників. Виходить практично те ж саме, що замінити фічафлаґи на чисельні константи (доволі очевидна оптимізація), тільки повністю автоматично.

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

До речі, для того використовуються слабкі вказівники — тип weak.Pointer. То є вказівники, які не впливають на збирання сміття. Тобто все, що робить функція очищення — це чекає, поки значення звільнить прибиральник, після чого видаляє вивільнені значення зі словника.


16.08.2024

Навіть прості справи варто записувати

Пʼятничний пост про GTD. Те, що варто не тримати все в голові, а краще записувати, це зрозуміло. Але… може хоч памʼятати “що зробити” це корисно? Цього тижня зловив розуміння, що як раз ні, навпаки.

Задача була така: “зателефонувати виконробу”. Ходив я з нею декілька днів. Скільки не згадував, так і не знайшов нагоди зателефонувати. Можна було б пояснити це прокрастинацією, тобто простими словами, лінню.

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

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

Це і є те, про що йдеться в GTD: не тримай справи в голові, записуй. Навіть якщо справа не велика — зовсім не обовʼязково її можна “взяти та зробити”.


15.08.2024

Cap'n Proto - серіалізація без копіювання

Роздивлявся формат серіалізації Cap’n Proto. Його особливістю є відсутність копіювання даних. Це значить, що коли у вас є буфер даних (та схема до нього), то значення можна забирати прямо з буфера. Фактично Cap’n Proto не стільки серіалізатор, скільки формат організації даних у масиві памʼяті.

Схожих рішень не так багато. Можу відзначити flatbuffers від Google як справжню альтернативу. Тут треба обовʼязково роздивлятися, як воно підтримує вашу платформу (в моєму випадку - Go.) Взагалі я б сказав, що рідко трапляються обставини, щоб саме серіалізація була вузьким місцем.

Проте мені подобається те, що з масивом даних можна працювати по шматочках. Наприклад, якщо у вас є JSON в 1 Гб, то доведеться весь його завантажити та розібрати — навіть коли з нього потрібне одне значення. А якщо Cap’n Proto, то можна прицільно забирати конкретні записи та значення. Це гарно працює з mmap.

Також за моїми вимірами масив даних Cap’n Proto займає суттєво менше памʼяті, ніж еквівалентні структури Go. Особливо по витратах на керування памʼяттю. Там, де у звичайних структур спостерігаю надлишок майже в 100%, у Cap’n Proto його практично немає. Та й не дивно, бо в памʼяті утримується єдиний звичайнісінький масив байтів.

З незручного: Cap’n Proto не підтримує зміну довжини масивів (без перестворення). Також відсутній тип-словник. Словників взагалі в zero-copy не бачу — ймовірно, тому, що для ефективної роботи їх доведеться пересортовувати. Але словник завжди можна замінити пошуком за відсортованим списком.

Наразі автор Cap’n Proto працює у Cloudflare, а сам формат широко використовується у Cloudflare Workers, зокрема для зберігання даних.


14.08.2024

Gzip та Base64

Коли потрібно надати двійкові дані в стислій текстовій формі, варто спочатку їх стиснути, а потім кодувати у Base64. Або, як наслідок, навіть якщо на сервері є автоматичне стискання на виході, краще все одно стиснути вхідні дані.

Чому так? Бо суть алгоритму Gzip - побудова словника з повторюваних послідовностей. (Алгоритмів взагалі є декілька, але зазвичай береться Лемпель-Зів-Велч, про нього й мова.) Повторювана послідовність залишиться такою в Base64 тільки тоді, коли вона вирівняна відносно “сітки” по три байти: Base64 кожні три байти перетворює в чотири символи. Ба більше, кожний випадок послідовності мусить бути вирівняний однаково: бо той самий байт на 1, 2, 3 місці в трійці буде представлений в Base64 абсолютно по-різному:

"foo" => Zm9v
" foo" => IGZvbw==
"  foo" => ICBmb28=
"   foo" => ICAgZm9v

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

А Base64 навпаки, все одно, що кодувати. Ефективність тут стала: хоч сирі дані, хоч стиснуті, хоч білий шум — все збільшиться в розмірі на 33%.


13.08.2024

Redis, AWS ElastiCache та Terraform

🤯 Мав нагоду попрактикуватись з конфігурацією AWS ElastiCache через Terraform. ElastiCache - то керований AWS сервіс для Memcached та Redis. Напевно, найгірша абстракція, яку я бачив у AWS, та Terraform нічого не спрощує.

Видно, що спочатку в ElastiCache був Memcached, а потім додали Redis. Бо те, що в них називається “кластер”, з Memcached містить декілька вузлів, а з Redis - тільки один. Але потім в Redis теж зʼявилася підтримка кластерів, її додали в ElastiCache та назвали “cluster mode cluster”. Гарно, так?

Окрім того, в Redis ще є реплікація. Кластери — то коли дані розбиті на декілька вузлів, а реплікація — коли кожен вузол містить копію. Звісно реплікацію теж можна увімкнути в ElastiCache, та зовсім все заплутати.

В Terraform є два ресурси для “кластерів” ElastiCache. aws_elasticache_cluster - це один вузол Redis, а aws_elasticache_replication_group - це декілька вузлів… серед яких можуть бути як репліковані вузли, так і ті, що складають кластер. Я вже не кажу, що це ще один шар незрозумілих назв, але також виникає проблема, що коли в Terraform хочемо перейти від одного вузла до декількох, доведеться замінити один ресурс іншим.

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

Як її розвʼязати? Редагувати стан. На превелику радість, за останні роки в Terraform зʼявилися повні можливості редагування стану через конфігурацію. Раніше для того треба було робити консольні команди — що було складно, якщо стан зберігається на CI. А тепер замінити один ресурс на інший можна одним блоком removed (щоб прибрати aws_elasticache_cluster зі стану, але не видалити сам вузол) та import (щоб внести всю групу як aws_elasticache_repication_group).

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


12.08.2024

Двійкові дані в текст: дробові кодування

Нам не обовʼязково обмежуватись кодуваннями, де розмір словника є ступенем двійки. Це, звісно, суттєво спрощує операції та дозволяє робити декодування таким псевдокодом (для Base64):

three_bytes = lookup[c0] + lookup[c1] << 6 + lookup[c2] << 12 + lookup[c3] << 18

Але ж будь-який інший розмір словника також технічно можливий. Фактично ми впроваджуємо систему числення з основою, що дорівнює розміру словника, та переводимо наші двійкові дані в та з неї. Єдине, що відрізняє цей процес від шкільної програми: вхідна послідовність розбивається на фрагменти такого розміру, щоб вони влазили в 64-бітне число. (А ще краще — у 48-бітне, що не втратить точності в типі Double та не створить сюрпризів, наприклад, у JavaScript.)

Як тривіальний приклад, розглянемо Base10. Тут кожний символ має одне з 10 значень. Два символи: 100 значень. Три: 1000 значень. Це більше за 256 - значить, трьома символами Base10 можна закодувати один байт. Надлишок такого кодування аж 300%. Хоча насправді за 3 байти можна впоратись й у Base7 (де 343 можливих значень.) А з Base10 надлишок буде менше, якщо кодувати 2 байти (65536 значень) у 5 символів: тільки 250%.

…Якщо це узагальнити, можна прийти до ілюстрації вище. Вона показує надлишок для всіх основ від 2 до 256, але тільки тих, де він зменшується (що в Base64, що в Base65 3 байти даних кодуються в 4 символи.)

Що ми бачимо? Краще за Base64 буде тільки Base85 - який, як згадали в коментарях, використовує Git, а до того ж ще й PostScript та PDF. Як я розумію, тут вирішили, що розмір файлу важливіший за швидкість обробки. Наприклад, в Git у Base85 кодуються патчі - операції з патчами не такі часті, отже можна й почекати.

Наступне покращення наступає на Base102 - для такого вже доведеться відкусити декілька системних символів. Що робить наше “текстове кодування” не таким вже й текстовим. Гадаю, це пояснює, чому окрім Base64 та Base85 інших кодувань немає. (Ну як, немає… Base16 ми теж скрізь використовуємо!)


11.08.2024

Що може бути краще за Base64?

Прочитав сьогодні в блозі Євгенія Гизили статтю про збірку WASM в складі пакета для браузера. Йшлося проміж іншим про кодування WASM у Base64 для включення у вихідний код JavaScript. (Бо WASM надає нам фактично обʼєктний модуль, який в JavaScript просто так не запакуєш.)

Виникла думка: ну, мабуть, Base64, стандартизований ще у 1996 році, в наш час Unicode та емодзі — не найкомпактніший спосіб кодувати двійкові дані в текст? Думка виявилась хибною: ось чому.

По-перше, стандарт Unicode, та конкретно UTF-8, випереджає Base64, бо зʼявився ще у 1992. Але то таке, історична дрібниця. По-друге, кодування символів у UTF-8 менш ефективне за Base64: символ довжиною у 2 байти кодується у 3, тобто з 50% надлишку замість 33%. (Пояснення тут в тому, що UTF-8 повинен бути сумісним з ASCII, тож всі байти символів Unicode містять виставлений верхній біт, а до того ще й маркери довжини символу).

Отже, як би ми не намагалися використати символи Unicode для кодування, нічого краще за Base64 не отримаємо.

(До речі, рядки в JavaScript мають кодування UTF-16 - єдине з кодувань Unicode, яке не сумісне з ASCII, тобто технічно можна було б досягти кращої ефективності. Але це тільки в памʼяті - а файл, який ми хочемо зменшити, все одно майже напевно буде в UTF-8.)

В ASCII7 128 символів. Не всі можна використовувати: символи 0-31 є системними. До того, важливо, щоб кількість символів в кодуванні була ступенем двійки: це дозволяє кодувати та декодувати з використанням побітових операцій та швидкої таблиці-словника. Виходить, 64 символи — це найкраща кількість, а Base64 - це математично найкраще кодування. Ось так.