200x збільшення продуктивності Papy: Шлях до 20 000 запитів/с — жодної секунди не можна витратити!

pic

Фото: Mimi Thian на Unsplash

Якщо ви ще не читали попередні пости, я надам трохи контексту щодо того, про що йдеться в цьому пості. Я написав відкритий C++ JSON фазер/тестер навантаження під назвою Papy(rus), який може запускати кастомізовані, випадкові або статичні пакети до серверів/ендпоінтів повторно. Papy доступний на GitHub за посиланням: noahpop77/Papy.

З моменту останнього великого оновлення Papy сталося кілька значних розробок. Як тільки основна функціональність була налаштована, настав час для довгого і складного шляху оптимізації! Були кілька моментів у процесі розробки, коли я думав, що «все, я завершив оптимізацію». Знаю, знаю — будь ласка, стримуйтесь від сміху.

Перш ніж зануритись в хронологію того, що відчувалося як моя особиста версія виконання праць Геркулеса, давайте визначимо мету. Для майбутнього проекту (який я поки що не можу поділитися) мета — змусити Papy обробляти 10 000 запитів на секунду. Враховуючи початковий рівень 100 запитів на секунду, коли почався процес оптимізації, досягнення цієї мети вимагало покращення продуктивності в 100 разів. Я хотів поставити мету, яка здавалася трохи непідйомною, але виявилося, що це не так!

Оскільки я фактично використовував Papy як тестову платформу для самонавчання C++, було кілька дуже смішних помилок, деякі менш очевидні помилки, а також помилки, які змусили мене розсміятись. І все ж, усі ви, хто працює з C++ і можете встановити Doom на мікрохвильовку, будьте ласкаві. Для контексту, я не був новачком у розробці програмного забезпечення перед тим, як почати, але ніколи не працював з настільки низькорівневими компільованими мовами, як C++ або C. Я дійсно був… Python Andy. Не буду брехати, це був я, коли вперше викликав сегфолт.

pic

Це не зовсім була соло-робота. На щастя, я познайомився з Doug Ilijev на сервері t3dotgg в Discord, де ми обговорювали труднощі сучасної веб-розробки. Можливість обмінюватися ідеями та кодом з ним, враховуючи його досвід, була дуже корисною.

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

Тепер, давайте слідувати пораді мого улюбленого персонажа у всіх творах і «не витрачати жодної секунди!».

100 запитів на секунду — початковий рівень

Все, що було зроблено з початку проекту до цього моменту, дозволило нам досягти 100 запитів на секунду. Зараз не було зроблено великих зусиль для досягнення «неймовірно оптимізованої» продуктивності, адже чесно кажучи, я не очікував, що присвячу цьому проекту стільки розвитку.

pic

170 запитів на секунду — gzip кодування

Технічно, це нова функція, але вона була додана для оптимізації продуктивності Papy. Gzip кодування має значно зменшити розмір запитів, що теоретично повинно збільшити пропускну здатність. Додавання цієї функції не було надто складним — просто застосувати заголовки до вихідного POST запиту та стиснути тіло корисного навантаження. Не так погано!

Продуктивність не змінилася кардинально, але я із задоволенням приймаю покращення з 100 до 170 запитів.

Нижче ви можете побачити два різні захоплення пакунків Wireshark. Перше показує, як виглядає запит без стиснення. Основне, що варто відзначити — це те, що тіло видно у правій панелі (не закодоване), і загальна довжина складає близько 18 000 байтів.

pic

Wireshark пакунки без gzip

Тепер, після внесення описаних змін, ми можемо чітко побачити результат.
Знову ж таки, у правій панелі ми можемо побачити вміст тіла, але цього разу вони явно стиснуті за допомогою gzip. Якщо ще раз подивитися на поле довжини, ви помітите розмір фактичних запитів. Цього разу розмір значно зменшений до ~2,250 байтів — приблизно на 800% менше!

pic

Wireshark пакунки з gzip

360 запитів на секунду — де-дуплікація випадкових чисел

Функція, яка регулярно викликається в коді, — це generateRandomInt(). Вона відповідає за генерацію псевдовипадкових цілих чисел у межах певного діапазону, які вставляються в шаблон JSON для імітації всіх фіктивних даних, що можуть бути захоплені в грі League of Legends. У цієї функції була проста неефективність: для кожного запиту вона оголошувала і ініціалізувала новий екземпляр об'єкта випадковості. Це було непотрібно, оскільки операція є обчислювально витратною і не гарантує ніякого покращення випадковості порівняно з використанням одного об'єкта випадковості повторно. Цей об'єкт було переміщено в глобальну область видимості класу myRandom, і тепер він не створюється марно знову.

int myRandom::generateRandomInt(int min, int max) {  
    std::random_device myRandom::rd;  
    std::mt19937 myRandom::gen(myRandom::rd());  
    std::uniform_int_distribution<> distrib(min, max);  
    return distrib(gen);  
} 
std::random_device myRandom::rd;  
std::mt19937 myRandom::gen(myRandom::rd());  

int myRandom::generateRandomInt(int min, int max) {  
    std::uniform_int_distribution<> distrib(min, max);  
    return distrib(gen);  
} 

830 запитів на секунду — пакетна генерація випадкових даних

Саме на цьому етапі я вирішив почати профілювати моє C++ застосування. Я вирішив використовувати FlameGraph за рекомендацією деяких дуже виразних джентльменів з Discord Primeagen (люблю вас усіх 😘). Коли я запустив профіль тривалістю 60 секунд для працюючого клієнта Papy, я зрозумів дещо. Ви помітили це?

pic

Так, майже 75% часу виконання Papy витрачалося на отримання випадкових об'єктів із JSON-об'єктів, які використовувалися для витягнення дійсних ігорних ідентифікаторів. Ми швидко застосували ту ж обробку, що й до generateRandomInt(), і побачили помітне покращення продуктивності.

Більш суттєве підвищення продуктивності виникло від переписування того, як робочі потоки отримують свої випадкові дані з цих JSON-об'єктів. Оригінальний метод передбачав парсинг JSON-об'єкта, витягування одного випадкового значення, його повернення і повторення цього процесу для всіх 120 випадкових об'єктів ігор — для однієї гри. Ви можете уявити, як цей процес виходить з-під контролю, коли мета — досягти 10 000 запитів на секунду.

Нова реалізація вводить пакетування для отримання всіх подібних елементів в один прохід. Наприклад, якщо потрібно отримати всі випадкові елементи, придбані гравцями в одній грі, ви просто парсите JSON-об'єкт мапінгу один раз і отримуєте 70 значень за один раз (10 гравців, по 7 елементів кожен), замість того, щоб отримувати їх по одному, що призвело б до 70 парсингів.
Ці 70 значень потім зберігаються у векторі рядків (std::vector), який повертається замість одного JSON-об'єкта.

Коли нам потрібно одне з цих 70 об'єктів, ми просто вибираємо перший, використовуємо його за потреби і видаляємо його з початку вектора — це діє як черга в певному сенсі.

pic

З одним пакетом випадкових даних ми досягли 400 запитів на секунду.

pic

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

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

10 = Кількість гравців на гру  
7 = Кількість елементів, необхідних на кожного  
n = Розмір JSON-об'єкта мапінгу (у парах ключ/значення)  

Попередній  
---------  
Розширена поліноміальна складність O(10*7*n)  
Викидаємо сталі O(n)  

Загальна складність парсингу JSON O(n)

Пакетування дозволило нам отримувати всі подібні елементи за один прохід парсингу. Тобто, якщо потрібно отримати всі випадкові елементи, придбані гравцями в одній грі, ми парсимо JSON-об'єкт мапінгу лише один раз і отримуємо 70 значень за один раз (10 гравців по 7 елементів кожен), замість того, щоб отримувати їх по одному, що призвело б до 70 парсингів.

10 = Кількість гравців на гру  
7 = Кількість елементів, необхідних на кожного  
1 = Розмір JSON-об'єкта мапінгу (у парах ключ/значення)  

Нове  
---------  
Розширена поліноміальна складність O(10*7*1)  
Викидаємо сталі O(1)  

Загальна складність парсингу JSON O(1)

Було одне, що я не врахував, і це як я застосовував дані, отримані з пакетної випадкової генерації. Я фактично циклічно створював кожного учасника гри, призначав їм перше значення з вектора для кожного JSON-об'єкта, що оброблявся в той час, а потім видаляв перший елемент вектора.

participant["championName"] = participantChamp[0];  
participantChamp.erase(participantChamp.begin());

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

pic

Якщо ж ми видаляємо з кінця, це операція O(1), тобто постійний час, оскільки для видалення елемента потрібна лише одна операція. Для Papy немає потреби використовувати метод "викидання з початку", оскільки всі дані все одно випадкові. Нам просто потрібен спосіб доступу до елемента і видалення його з структури даних. Метод "викидання з кінця" набагато ефективніший.

pic

Той самий підхід застосовувався і до інших полів, що стосуються інших даних, що генеруються випадковим чином (ім'я персонажа, заклинання, обладнання), і нам вдалося значно підвищити продуктивність до 830 запитів на секунду!

900 запитів на секунду — використання рідних JSON-об'єктів

До цього моменту ми зберігали наші JSON-об'єкти як рядки, перетворювали їх у використовувані JSON-об'єкти, а потім виконували необхідні операції. Однак для великих JSON-об'єктів ці дії перетворення можуть бути обчислювально дорогими. Бібліотека nlohmann::json, яку ми використовуємо для маніпулювання JSON-об'єктами, фактично підтримує змінні рідних JSON-об'єктів.
Отже, якщо теоретично ми перетворимо всі наші рядки на справжні C++ об'єкти nlohmann::json, нам не доведеться виконувати жодне перетворення! Збільшення продуктивності було непоганим, але робота з JSON-об'єктами стала значно чистішою з цього моменту.

pic

std::string JSON об'єкт, який потребує перетворення

pic

nlohmann::json об'єкт

2400 запитів на секунду — Без дублювання випадкових пристроїв

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

  • Випадковий рядок певної довжини
  • Випадковий рядок лише з чисел певної довжини
  • Випадкове булеве значення

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

pic

3200 запитів на секунду — Значення, визначені під час компіляції

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

std::string myRandom::generateRandomString(size_t length) {  
 std::string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";  

 std::uniform_int_distribution<> distrib(0, chars.size() - 1);  
 std::string randomStr;  
 for (size_t i = 0; i < length; ++i) {  
 randomStr += chars[distrib(gen)];  
 }  
 return randomStr;  
}

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

std::string myRandom::generateRandomString(size_t length) {  
 constexpr char chars[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";  
 constexpr size_t chars_len = sizeof(chars) - 1;  

 static std::uniform_int_distribution<> distrib(0, chars_len - 1);  
 std::string randomStr;  
 for (size_t i = 0; i < length; ++i) {  
 randomStr += chars[distrib(gen)];  
 }  
 return randomStr;  
}

Тепер рядки в C++ по суті є векторами, і ми знаємо поведінку росту векторів. Чому б нам не зарезервувати пам'ять для рядка заздалегідь і не витрачати операції на автоматичне розширення рядка?

std::string myRandom::generateRandomString(size_t length) {  
 constexpr char chars[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";  
 constexpr size_t chars_len = sizeof(chars) - 1;  

 static std::uniform_int_distribution<> distrib(0, chars_len - 1);  

 std::string randomStr;  
 randomStr.reserve(length);  
 for (size_t i = 0; i < length; ++i) {  
 randomStr += chars[distrib(gen)];  
 }  
 return randomStr;  
}

З цими змінами ми змогли досягти приблизно 3200 запитів на секунду! ТЕПЕР МИ КУШТУЄМО.

pic

8000 запитів на секунду — Копіювати, копіювати, копіювати, копіювати

Оглянувши оновлений профіль Papy, я зрозумів, що майже 40% її обчислень були пов'язані з методом, який повертає пакет випадкових елементів, які ми запитуємо для кожної гри.
Отже, є дві основні проблеми. По-перше, метод використовує новий випадковий пристрій для кожного виклику (що вже застаріло на даний момент, я вважаю). Щоб зберегти код чистим та оптимізованим, метод getRandomVectorFromJSON() був переміщений до класу myRandom разом з рештою коду для випадковізації. Це також дозволило використовувати один і той самий випадковий пристрій і мати однакову поведінку в одному місці.

pic

Інша частина, яка була неправильною, полягає в тому, що getRandomVectorFromJSON() була досить великою неефективністю в структурі коду. Ми оголошували вектор, резервували для нього пам'ять, потім викликали getRandomVectorFromJSON() і присвоювали повернуте значення оригінальному std::vector. Проблема полягала в тому, що всередині методу getRandomVectorFromJSON() ми ініціалізували ще один std::vector, резервуючи пам'ять для нього, присвоюючи значення, які ми мали повернути, і повертавши новий вектор.

std::vector participantItems;  
participantItems.reserve(1400);  
participantItems = getRandomVectorFromJSON(mapping::ITEMS_JSON, 70);
std::vector matchBuilder::getRandomVectorFromJSON(const nlohmann::json& jsonObject, const int& count) {  
 std::vector returnKeys;  
 returnKeys.resize(count);  
 // решта коду...  
}

pic

Зробивши це, ми присвоювали вектор, створений у методі getRandomVectorFromJSON(), як повернуте значення до participantItems, і таким чином ефективно усували оригінальне резервування для participantItems, але все одно витрачали операції для цього. Додаткові алокації пам'яті з резервів були усунені, і ми почали передавати оригінальний вектор за посиланням, щоб працювати лише з одним вектором.

7600 запитів на секунду — Важка сегментація

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

pic

На цьому етапі в моїй голові почало літати багато думок:

Ця сегментаційна помилка була ТУЖЕ важко ловити. Це як боксувати з тренованим 22-річним хлопцем на 400 мг кофеїну і з кісткою для боротьби. Я отримав удар і не міг відповісти.

pic

Моя точка зору при спробах зловити сегментну помилку

Однак є кілька фактів:

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

Я перевірив, чи є витік пам'яті за допомогою valgrind, але його не було.

pic

На щастя, мій досвід з курсу з аналізу шкідливого програмного забезпечення допоміг, і я згадав, що можу використовувати GDB (GNU Project Debugger) для отримання детальніших відомостей про те, що відбувається. Ми змогли побачити, як кожен потік працює, а також подивитися на зворотний слід, який по суті є подіями, що відбувалися в конкретному потоці, який викликав помилку сегментації до того, як вона сталася.

pic

pic

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

Ось що я помітив, коли будував тестову версію Papy, намагаючись налагодити помилку: виникла помилка лінкування, яка раптово з'явилася.

g++ obj/apiClient.o obj/cliHelper.o obj/main.o obj/mapping.o obj/matchBuilder.o obj/millisecondClock.o obj/myRandom.o obj/oceanBuilder.o obj/threadWorks.o -o bin/papy -Lsrc/dependencies/openssl/include -lssl -lcrypto -lz  
/usr/bin/ld: obj/myRandom.o:(.bss+0x0): multiple definition of `myRandom::rd'; obj/matchBuilder.o:(.bss+0x0): first defined here  
/usr/bin/ld: obj/myRandom.o:(.bss+0x13a0): multiple definition of `myRandom::gen'; obj/matchBuilder.o:(.bss+0x13a0): first defined here  
/usr/bin/ld: obj/matchBuilder.o: warning: relocation against `_ZN12matchBuilder3genE' in read-only section `.text'  
/usr/bin/ld: obj/matchBuilder.o: in function `matchBuilder::getRandomVectorFromJSON(nlohmann::json_abi_v3_11_3::basic_json, std::allocator >, bool, long, unsigned long, double, std::allocator, nlohmann::json_abi_v3_11_3::adl_serializer, std::vector >, void> const&, int const&)':  
matchBuilder.cpp:(.text+0x1aeb): undefined reference to `matchBuilder::gen'  
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE  
collect2: error: ld returned 1 exit status  
make: *** [makefile:38: bin/papy] Error 1

Що ми тут бачимо?

undefined reference to `matchBuilder::gen'

Виявляється, що причина сегментаційної помилки полягала в тому, що існуючий випадковий пристрій для ініціалізації та генерації даних використовувався ЗАНАДТО ШВИДКО. Раніше ця помилка не виникала, оскільки просто не було достатньо потоків, що часто зверталися до пристрою випадкових чисел і не стикалися між собою. Але тепер, коли кількість запитів сягала близько 8300 на секунду, ймовірність виникнення умови гонки була НАБАГАТО вищою.

Виправлення було простим: зробити пристрої випадкових чисел локальними для кожного потоку (thread_local). Це збільшило накладні витрати та знизило продуктивність, але значно покращило стабільність. Адже немає сенсу рухатися швидко, якщо ти відразу з'їдеш з дороги, як тільки буде поворот 0_0.

20 000 запитів на секунду — Велике усвідомлення

Цей заголовок може бути шоком, і коли я побачив, що викликало проблеми тут, я просто офігів. Відступивши від комп'ютера і вирушивши на прогулянку після того, як завершив 12 раундів з однією сегментаційною помилкою, я зрозумів одне. Під час мого дебагінгу за допомогою операторів print (між використанням gdb та valgrind), мені на думку прийшли дві цікаві поведінки. Перша з них була, що коли я запускав Papy в режимі з 16 потоками, що повинно було навантажити мій процесор (CPU, який підтримує 16 потоків), монітор показував 60% завантаження. Що?! Друга дивна думка полягала в тому, що кожен оператор print, який я додавав для дебагінгу, знижував кількість запитів на секунду. Потім я згадав твіт від Джона Кармака, і я присягнуся, я побачив матрицю.

pic

Саме в той момент я побіг додому так швидко, як тільки міг, минаючи маленьких собак, старих жінок (вибач, Яніс), перестрибуючи через 3-метрову стіну навколо мого житлового комплексу, вбігаючи до моєї квартири, сів за стіл.

Основним способом відстеження того, що відбувається з Papy під час виконання, є рядок метрик, який виводиться на екран і "оновлюється" з кожним запитом за допомогою оператора повернення каретки для перезапису того, що знаходиться на поточному рядку. Коли Papy працював на 100 запитів на секунду, це не було великим проблемою.
Тепер, коли ми досягли 8000 запитів на секунду, причина того, що мій процесор був заблокований на 60%, полягала у всіх блокуючих операціях, які виникали у потоках, що чекали запису на екран, замість того, щоб обробляти і надсилати більше запитів...

Я також використовую Ghostty, який є швидшим терміналом, ніж більшість, що робить цю проблему ще більш комічною.

// Лінія метрик  
std::cout << "\rTotal Sent: " << totalPayloadsSent  
 << " | Successful: " << totalPayloadsSuccessful  
 << " | Failed: " << (totalPayloadsSent - totalPayloadsSuccessful)  
 << " | Packets/s: " << packetsPerSecond  
 << " | Elapsed Time: " << clock.elapsedMilliseconds()  
 << " " // очищає кілька символів після кінця  
 << std::flush;

Блок коду, що оновлює лінію метрик терміналу через задані інтервали, був доданий для контролю того, як часто ми друкуємо на екран, і тепер ми справді можемо побачити, НАСКІЛЬКИ швидко може працювати Papy.

// Doug Ilijev PR  
static auto last_update_time = std::chrono::steady_clock::now();  
auto now = std::chrono::steady_clock::now();  
auto elapsed = std::chrono::duration_cast(now - last_update_time);  
constexpr unsigned int DELAY_MS = 100;  
if (elapsed.count() < DELAY_MS) {  
 return;  
}

pic

Тепер, коли ми більше не витрачаємо купу часу процесора, чекаючи на можливість запису на термінал, ми можемо побачити більш відображене використання апаратного забезпечення. Те, що ми знайшли, це те, що можемо ЗБІЛЬШИТИ кількість потоків. Я змушений був змінити команду, яку я використовував для тестування Papy раніше. Спочатку я тестував з 16 потоками.

bin/papy --threads 16 --target "http://127.0.0.1" ... решта команди

Тепер, щоб повністю завантажити процесор, мені потрібно було запустити ще кілька потоків. Щоб повністю заповнити процесор, потрібно було збільшити кількість потоків до близько 120.

bin/papy --threads 120 --target "http://127.0.0.1" ... решта команди

З цього моменту ми почали бачити реальну кількість запитів, яку Papy здатен обробити:

Доступ до розгортання моєї веб-програми через її відкритий RESTful API, який надсилає запити INSERT до підключеної PostgreSQL інстанції, давав близько 12 000 запитів на секунду. Це означає 12 000 подій створення ігор у базі даних за секунду.

pic

Доступ до імітованого GO-ендпоінту, який симулює сервіс без підключення до бази даних, що просто локально обробляє отриманий JSON, давав понад 20 000 запитів на секунду!

pic

Імітований GO-ендпоінт БЕЗ бази даних

pic

Локальне розгортання веб-програми З базою даних

Усі ці оптимізації Papy і я ще носив важкі наручники!

pic

Відчуття, коли знімаєш обмеження часу на запис в екран

Висновок

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

Дякую за читання! Не соромтеся коментувати або перевірити GitHub для Papy.

Перекладено з: 200x Papy Performance Increase: Road To 20,000 Requests/sec — Can’t Waste A Single Second!

Leave a Reply

Your email address will not be published. Required fields are marked *