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

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

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

05.03.2023

Як Ruby, Node.js та Go витрачають памʼять на функції

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

Власне, що значить — зберегти функцію? Код функції памʼяті не займає, принаймні, не в тому сенсі, як змінна. Та дійсно, у всіх мовах розмір функції ніяк не впливає на витрати памʼяті. В кожній мові посилання на функцію займає по-різному. В Go - чесні 8 байтів, в JavaScript - 70, в Ruby - цілих 241 байт. Знову відчувається різниця між системною мовою, мовою з функціональною парадигмою, та мовою, де лямбду нечасто побачиш.

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

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

В Ruby все навпаки. В Ruby лямбда замикає всі доступні змінні — незалежно від того, використовуються вони в ній чи ні. Це важливо знати, бо всі ці змінні утримуватимуться в памʼяті, поки існує лямбда. Чому так? Я не впевнений, але здається, тому, що в Ruby є метод Binding#local_variable_get, яким можна за імʼям дізнатись значення будь-якої змінної. Та дійсно, цим методом можна підгледіти змінні поза межами лямбди:

def hidden_binding(i)
  j = i * i
  ->(name) { binding.local_variable_get(name) }
end
peek = hidden_binding(2)
puts peek.call(:j) # 4

Отакої! Гнучкість мови Ruby дається не безплатно.

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

function hiddenBinding(i) {
  let j = i * i;
  return (x) => eval(x);
}
console.log(hiddenBinding(2)("j")); // 4

function hiddenBinding2(i, f) {
  let j = i * i;
  return (x) => f(x);
}
console.log(hiddenBinding2(2, eval)("j")); // j is not defined

(Ясно, що порядні люди пишуть код без eval. Але он воно як цікаво працює!)


04.03.2023

Вимірюємо витрати памʼяті у JavaScript

Для закінчення моєї поверхневої серії про витрати памʼяті, розглянемо JavaScript. А точніше, інтерпретатор Node.JS, бо інтерпретаторів існує чимало, та поводитись вони можуть по-різному. Код все ще на gist.

В JavaScript є вбудована функція для отримання витрат памʼяті - process.memoryUsage(). А от для виклику збирача сміття треба запускати скрипт з ключем --expose-gc, інакше функція global.gc() не буде доступною.

Так само як і в Ruby та Go, числа в JavaScript займають 8 байтів, тобто зберігаються без обгорток. Так само масив чисел займає стільки памʼяті, скільки самі числа. Виникає питання — навіщо тоді в JavaScript є ще класи для типізованих масивів - Int8Array та інші? Знайшов пояснення. Головна причина в тому, що типізовані масиви відповідають єдиному блоку памʼяті, та їх можна передавати в усілякі API, які такий блок очікують. Тож не варто заміняти свої масиви на типізовані, якщо немає на них споживача — звичайні масиви не менш ефективні.

Константні рядки так само зберігаються одноразово. Але це не єдине, що тут незвично. Рядки у JavaScript представляються у кодуванні UTF-16, де “прості” символи займають по 2 байти. А памʼять під них Node.js виділяє блоками по 8 байтів, напевно, щоб рядок був кратним розрядності процесора. До речі, не писав про це окремо, але як в Ruby, так і в Go рядки зберігаються в “рідному” кодуванні, що для більшості сучасних програм значить UTF-8. Так що, для зберігання текстів латиницею програма на JavaScript споживатиме вдвічі більше памʼяті, ніж Ruby або Go. Але тут теж все не так просто, бо мені здається, що Node.JS стискає рядки, бо на рядки з повторюваними значеннями уходить менше памʼяті. Треба це детальніше роздивитись.

Зі структурами все теж цікаво. В JavaScript все є обʼєктом. Будь то асоціативний масив, клас, будь-що інше. Це підтверджують мої тести: обʼєкти, створені літералом, та такі, що створені конструктором, памʼяті займають однаково. Але з літералами не все так просто. Помітив, що додаткове чисельне поле в літералі збільшує витрати тільки на 8 байтів. Але ж де тоді зберігається ключ цього поля? Моя інтуїція каже, що в JavaScript структура обʼєкта зберігається окремо від значень, тому обʼєкти, створені одним та тим самим літералом містять тільки свої значення. Таким чином, JavaScript по використанню памʼяті точно краще, ніж Ruby, та має показувати витрати, схожі на Go. Дуже приємний висновок.

На цьому поки закінчую дослідження витрат памʼяті. Короткі висновки: для зберігання простих типів та масивів всі 3 мови підходять приблизно однаково; Ruby суттєво програє в витратах на структури; JavaScript навпаки, майже не гірше за системну мову; Go, звісно, буде оптимальним, якщо тільки не зберігати все в асоціативних масивах.


03.03.2023

Витрати памʼяті Ruby та Go, продовження: структури

Продовжую вчорашні тести. Доробив код, зробив зручну функцію для вимірювання, щоб прибрати копіпасту. На Go згодилися узагальнені типи; безумовно, корисна можливість, та ще й така що не погіршує швидкодію програми — про це далі. Сьогодні роздивимось структури. За тестовий приклад я обрав структуру з трьох чисельних значень; тоді розмір самих значень відомий - 24 байти, та можна підрахувати накладні витрати від самого механізму структури.

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

Хеш інтуїтивно буде менш ефективним. Тест це підтверджує: тут, на перший погляд, витрати аж в 3 рази більше. Але ось що цікаво: той самий хеш розміром у 224 байти спроможний зберігати до 8 значень без додаткового виділення памʼяті!. Схоже на те, що памʼять виділяється блоками, бо з девʼятого значення використання стрибає у 2 рази. Виходить, що для маленьких структур краще клас, а для великих можна не перейматись, бо хеш не додає зайвих витрат. Також, хеш, де ключі — це рядкові константи, важить так само як і хеш з символами. Ось скільки дивних нюансів такого простого, на перший погляд, типу.

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

Вказівник на структуру додасть свої очевидні 8 байтів. Більш цікаво, що перетворення типу в interface{} додає 9 байтів — тобто якщо використати конкретні типи, то вони не мають додаткових витрат, а інтерфейсний тип — має. Що підкреслює цінність впровадження узагальнених типів, бо як раз узагальнені типи працюють так само як еквівалентний код, де всі типи підставлені вручну.

Про асоціативні масиви в Go коротко скажу, що вони дуже схожі на хеші в Ruby. Таке саме виділення памʼяті блоками, та приблизно такі самі витрати на елемент — десь 24 байти. Різниця тільки в тому, що в Go структури набагато вигідніше в порівнянні. Наприклад, якщо використати тип map[string]interface{} для лінивої обробки JSON, то платити доведеться не тільки за масив, а ще й за інтерфейсний тип. Так що інтуїція по Go - якщо форма структури відома заздалегідь, завжди треба брати структури, а якщо ні — тільки тоді масиви.

Завтра — все то саме, тільки для JavaScript.


02.03.2023

Порівняння витрат памʼяті Ruby та Go на масиви

Хотів написати пост про те, як Go краще витрачає памʼять, ніж Ruby, та ще й з прикладами. Але вийшла несподіванка — не такий вже Ruby й поганий! На масив чисел якимсь чином витрачає десь стільки ж памʼяті (на 2% менше навіть!), а от на масив рядків вже на 50% більше, що більш зрозуміло. Код прикладів у gist. Тут треба більше розбиратись.

По-перше, що вимірювати як витрати пам’яті. На мою думку, краще за все дивитись на загальний розмір памʼяті, виділений процесом, тобто RSS - Resident Set Size. Ані в Ruby, ані в Go немає вбудованих засобів дізнатись цей розмір, але його можна легко отримати командою оболонки ps -o rss $PID_OF_PROGRAM. Корисно, що цей показник не спирається на особливості мови чи платформи, тому таке порівняння більш справедливе. Залишається тільки виділяти достатньо памʼяті. Та про всяк випадок викликати збирач сміття перед вимірюваннями; хоча в такому простому тесті це не має ніякого ефекту. Збирати сміття варто, якщо вимірювати витрати цілого алгоритму.

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

Рядки на Go витрачають, окрім байта на кожний символ, додатково 8 байтів на вказівник, та 8 байтів на довжину рядка (або так це виглядає). В Ruby, напевно, рядок містить ще якесь значення — можливо, кодування.

Можемо також підтвердити економію, яку Go отримує на тому, що рядки — є константи]. А саме, якщо масив наповнити не різними рядками, а одним й тим самим рядком, то витрати памʼяті будуть такі самі, як на масив чисел. І це незалежно від довжини цього рядка. Це пояснюється тим, що фактично масив містить вказівники на один та той самий рядок, а вказівник займає ті самі 8 байтів, як і число. В Ruby можна досягнути такої самої економії, якщо робити масив символів, тобто :symbol. Що зайвий раз підкреслює важливість використання символів там, де одне й те саме значення повторюватиметься багато разів — наприклад, при декодуванні JSON.

Але масиви — то надто просто. Цікавіше буде порівняти витрати на структури або хеші — фундамент сучасного програмування. Про це — наступного разу.


01.03.2023

Коли кешування зробити неможливо

Сьогодні коротенькі міркування про той випадок, коли кешування зробити неможливо.

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

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

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

Можна зробити інакше та кешувати всю базу заздалегідь, а потім регулярно оновлювати. Таке рішення насправді нерідко зустрічається, наприклад, антивіруси завантажують локальну копію вірусної бази, а не питають кожного разу в сервера.

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


28.02.2023

Як запустити будь-який скрипт на AWS ECS

Забув вчора розповісти про головне. А саме — бенчмарки треба було запускати на AWS ECS, в умовах, наближених до реальних. От тільки є проблема — на ECS запускаються тільки додатки, які вже запаковані в образи Docker та завантажені у реєстр. Що не сприяє запуску випадкових скриптів та швидким ітераціям розробки.

Але вихід є — при запуску контейнера ECS дозволяє замінити його командний рядок. Тоді скрипт можна розмістити цілком в новій команді, та таким чином запустити. Тільки вручну це робити складно, тому ми в команді зробили утиліту ecs_run, яка власне й робить те, що я написав. Утиліта ecs_run дозволяє нам запускати усілякі rake скрипти, а також просто довільний код на Ruby. (Щоб виконати Ruby-код, вона загортає його у виклик інтерпретатора ruby.)

Тобто, у нас вже є сервіс ECS, в якому міститься додаток, готовий до запуску — код, залежності, необхідні змінні оточення та дозволи. Я командою ecr_run <benchmark.rb запускаю свій бенчмарк безпосередньо в цьому середовищі, тому він має доступ до всього, що є в додатку.

Ось тільки заміна командного рядка в ECS обмежена розміром 8KB. (Як я дізнався вчора; раніше це не заважало.) Що тоді робити? Я придумав розміщати сам скрипт у секретному Gist, а в контейнер передавати обгортку, яка завантажить його та запустить. Якщо цю ідею розвинути, то можна взагалі пакувати всі змінені файли в архів, архів передавати в контейнер, розпаковувати, та мати “неофіційну” версію додатка без зайвого розгортування.

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


27.02.2023

Поради по бенчмарках на Ruby

Сьогодні вдалося зробити чудовий бенчмарк для моїх поробок на Redshift. Власне, сам бенчмарк робився на Ruby, та порівнював швидкодію різних запитів в базу. Поки писав, знайшов пару цікавих рішень.


26.02.2023

Де StackOverflow для ремонту?

Вихідні пройшли за ремонтом, а точніше — оновленням проводки та домашньої мережі. Проєкт на 7 розеток, з яких одна - Ethernet, а інша — контрольована, а також два Ethernet кабелі через всю квартиру, та пересування роутера з кімнати на поличку в коридорі (яку теж довелося встановити.)

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

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

Проблема погіршується тим, що існує купа регіональних особливостей. Всі знають, що розетки в США інші; але також інші стандарти труб та фітингів — навіть кран з польської IKEA не вдасться просто взяти та встановити в Україні. Якщо про програмування можна відразу шукати англійською, то з ремонтом це загрожує неприємними регіональними сюрпризами.

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


25.02.2023

Інтуїція про швидкодію мов програмування

Яка мова програмування швидка, яка ні? Загальне правило тут старе та просте: інтерпретовані, тобто скриптові, мови — повільні, компільовані — швидкі. Але сучасна арена технологій складніша. Будь-яка успішна інтерпретована мова — будь то Ruby, JavaScript, або Perl - компілюють код під час виконання, щоб його прискорити.

Взагалі програми повільні через невизначеність. Чим більш чітко мова задає програму та її структури даних — тим краще буде працювати. (Компіляція під час виконання саме й аналізує код на наявність “визначеної” частини.) Наочний приклад: Rust проти Go; в Rust треба явно назначати “власність” памʼяті, проте це дозволяє уникнути збірки сміття, яку вимушений робити Go; тож Rust - швидша мова. Керування памʼяттю — це найголовніший фактор невизначеності, що робить програми повільними; системною мовою програмування С вся памʼять виділяється та звільняється вручну — абсолютно визначено та без зайвих витрат часу. Але ручне керування робити складно, та призводить до критичних помилок. Тому більшість сучасних програмістів користуються мовами, що звільняють памʼять самі, неявно. Проте ефективне автоматичне керування памʼяттю — досі не вирішене питання (про що також свідчить існування мови Rust.)

Окрім керування памʼяттю, дорого коштує відсутність типів та усіляка гнучкість. Наприклад, програма на Go під час виконання чітко знає, де в памʼяті знаходиться та чи інша функція; а програма на Ruby має спочатку перевірити, чи не заманкіпатчили її, та взагалі, чи є така функція у даного обʼєкту.

Скриптові мови існують, бо не завжди потрібна максимальна швидкодія. Здатність писати простіші (та менш визначені) програми теж дуже цінна, бо це дозволяє досягнути складнішої логіки меншим кодом. До того ж будь-яка скриптова мова — це неодмінно тільки шар, під яким знаходиться швидкий компільований код. Причому шар порівняно тонкий. Наприклад, може здатись, що в вебі переважають саме скриптові мови. Але ж за додатком на Ruby або PHP або Python стоїть база даних, написана на мові C. А перед ним — балансувальник навантаження, теж на мові C. Тож наш додаток не так вже й багато робить сам по собі.


24.02.2023

Погано чи ненормально?

За результатами вимірювань, інструмент для виявлення SpamAssassin обробляє один лист за 25 секунд. Це добре чи погано?

…Питання з каверзою. Головне, що це ненормально. Пробачте за відсутність кращих термінів, але я маю на увазі, що якщо вимірювання показали такий результат, то щось пішло не так. Звідки це випливає? Наприклад, з того, що SpamAssassin - широко використовувана система, а листи приходять частіше, ніж раз на 25 секунд, тож з такою швидкодією вона просто має не впоратись.

Тож в такому випадку треба зупинитись та зрозуміти, що саме в процесі вимірювань працює не так. Може, клієнт не може під’єднатися до сервісу SpamAssassin, та просто повертає порожній результат після тайм-ауту… Та багато чого може бути, головне, що цей результат надто ненормальний, щоб з ним працювати далі (наприклад, базувати оцінки, або шукати шляхи оптимізації.)

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