Оптимізація – ваш найлютіший ворог, Різне, Програмування, статті

Переклад: Андрій Лягусскій

Для початку дозвольте привернути вашу увагу до моєї персони. Я серйозно!


Трохи загальної інформації: моя докторська робота була однією з перших робіт по автоматичної генерації оптимізують компіляторів з формального машинного опису («Машинно-незалежна генерація оптимального коду», університет Карнегі-Меллона (надалі CMU – Прим. перекл.), кафедра інформатики, 1975). Після захисту докторської я провів три роки в CMU в якості ведучого дослідника багатопроцесорної комп’ютерної системи Cmmp, що використала нашу місцеву операційну систему Hydra, безпечну і високопродуктивну. Потім я повернувся до дослідження компіляторів на проекті PQCC (компілятор компіляторів промислового рівня), який, в кінцевому рахунку, привів до основи лабораторій Tartan (зараз поглинені Texas Instruments) – компанії з розробки компіляторів, в якій я працював в інструментальній групі. Я провів півтора десятка років за написанням та використанням інструментів вимірювання продуктивності.


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


Оптимізація: Що і Коли


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


Отже, ви оптимізуєте тому, що у вас проблеми з продуктивністю. Іноді це оптимізація обчислень: маніпуляція картинкою занадто повільна. Іноді це оптимізація доступу до даних: занадто багато часу потрібно для завантаження даних. А іноді це оптимізація алгоритмів: ви помилилися в алгоритмічної основі. Якщо ви не розумієте різницю між квадратичної складністю сортування, і складністю n log n, У вас напевно проблеми, хоча саме по собі таке знання зовсім даремно.


Пару років тому я працював над складною програмою, яка повинна була виконувати семантичні крос-перевірки між «висловами» в коді програми і «Оголошеннями». Я виявив, що обчислення мають складність n3 (Взагалі-m * n2, Але в більшості випадків m було порівняно з n). І тут у вас три шляхи:



Єдиний вірний шлях при оптимізації – це інженерний підхід. Я досліджував продуктивність, і на найбільшому «реальному» прикладі, який у нас був, я виявив, що практично завжди n дорівнювало 1, іноді 2, рідко 3, і тільки один раз 4. І це були дуже маленькі значення, щоб проявляти неспокій. Звичайно, складність алгоритму дорівнювала n3, Але при цьому значення n були настільки малі, що необхідності переписувати код не виникло. Переписування коду являло собою складну задачу, затримало б весь проект на пару тижнів і зажадало б додатково по кілька покажчиків в кожному вузлі дерева в і без того тісному адресному просторі мінікомп’ютера.


Також я написав розподільник пам’яті, який все використали. І я провів купу часу, відточуючи його продуктивність – щоб це був найшвидший розподільник у своєму класі. Всі ці пригоди детально описані в книзі «IDL: The Language and its Implementation», зараз, на жаль, уже вийшла з друку (Nestor, Newcomer, Gianinni and Stone, Prentice-Hall, 1990). Одна група, яка використала цей розподільник, також використовувала спеціальний інструмент для вимірювання продуктивності на Unix. Ця програма визначала, де в даний момент крутиться лічильник виконання (program counter), і через достатній час надавала «гістограму щільності», що показує, скільки часу профільована програма проводила в кожному блоці коду. І цей інструмент показав, що левову частку свого часу виконання програма проводила в розподільнику пам’яті. Для мене це не мало значення, але всі пальці вказували в моєму напрямку.


Тоді я написав невеличку зачіпку в розподільнику, яка підраховувала кількість його дзвінків. І цей лічильник показав, що розподільник викликався більше 4000000 разів. Жоден виклик не займав більше часу, ніж мінімальний вимірний інтервал в 10 мікросекунд (приблизно десять інструкцій на нашій 1-MIPS машині), але 40000000 мікросекунд – це 40 секунд. Звичайно, загальний час було ще більше, тому що треба врахувати 4000000 операцій звільнення пам’яті, які були, звичайно, швидше, але все-таки розподільник займав більше 50% часу виконання всієї програми.


Чому таке відбувалося? Тому що, з невідомих для програмістів причин, критична функція, яку вони викликали у внутрішньому циклі своїх алгоритмів, виділяла блок пам’яті в 5-10 байт, робила свою роботу і звільняла його. Коли ми змінили цю поведінку на 10-байтовий локальний стековий буфер, час, займане розподільником по відношенню до загального часу виконання всієї програми знизилося до 3%.


Без додаткової інформації ми не змогли б визначити, чому розподільник викликався так багато разів. Профілюючі інструменти, засновані на лічильнику виконання – це дуже слабкий клас інструментів. Видавані ними результати часто бувають підозрілими. Ви можете звернутися до моєї статті «Профілювання продуктивності» в Dr.Dobb’s Journal за січень 1993. (тільки для зареєстрованих користувачів – прим. перев.)


Класичний випадок грубої помилки при оптимізації кілька років тому допустила одна з найбільших компаній розробки програмного забезпечення. Ми мали справу з їхньої першої інтерактивною системою, що працює в режимі поділу часу, і ця робота дозволила нам набратися нового досвіду різними способами. Одна з таких можливостей випала на долю групи, яка працювала з компілятором FORTRAN. Зараз будь-розробник компіляторів в курсі, що чим більшу хеш-таблицю він використовує для пошуку символів, то більша буде продуктивність при пошуку. Коли ви розробляєте багатопрохідний компілятор на мейнфрейми з 32 кілобайтами пам’яті, в результаті ви прийдете до відносно маленькій таблиці символів, але будете використовувати дуже, дуже хороший алгоритм хешування, так що ймовірність колізії при хешуванні зменшується (на відміну від двійкового пошуку, який має складність log n, Хороша хеш-таблиця має константне час доступу щодо деякої щільності таблиці, так що поки ви тримаєте щільність нижче цього порога, можна очікувати, що вартість доступу до символу або його додавання в середньому буде дорівнювати 1 або 2. Відмінна хеш-таблиця (Яка зазвичай обчислюється заздалегідь для константних символів) має константне час доступу в районі 1.0 або 1.3; коли досягається значення 1.5, Хешування потрібно переробити).


І ось, ця група, що працювала з компілятором, виявила, що у них тепер не 32 кілобайти пам’яті, і не 128, і навіть не 512. Замість цього у них з’явилося 4 гігабайти віртуального адресного простору. «Ей, а давайте-ка зробимо
дійсно велику хеш-таблицю », – заволали вони від радості. «Наприклад, як щодо таблиці в 1 мегабайт? ». Сказано – зроблено. Однак крім цього у них ще був надзвичайно витончений компілятор, розроблений спеціально для маленьких, густозаселених хеш-таблиць. В результаті, так як символи були рівномірно розподілені по 256 4-кілобайтовим сторінкам в цьому одному мегабайті,
кожна операція звернення до символу з таблиці приводила до помилки відсутності сторінки в пам’яті. Компілятор виявився тією ще сволотою. Коли ж нарешті група вирішила повернутися до 64-кілобайтні таблиці, незважаючи на те, що алгоритм став гірше за «абсолютної» продуктивності з чисто алгоритмічної точки зору (більше машинних інструкцій потрібно для пошуку символа), він не викликав так багато помилок при зверненні до відсутньої сторінки пам’яті, і тому став працювати на порядок швидше. Так що ефекти третьої сторони
мають значення.


Крім того, уникайте З. Ні, не швидкості світла. Коли ми говоримо про ефективності, з алгоритмічної точки зору це записується як C *
f(n)
. Так, квадратичний алгоритм формально буде позначений як C *
n2
, Що означає «константа помножена на квадрат кількості оброблюваних елементів ». Це скорочується до O(n2), Або «Квадратичної складності», а в побуті прийнято опускати слово «складність». Але ніколи не забувайте, що у вас ще є C. Колись давно я виконував проект, який на виході видавав набір звітів, сортованих різними способами. Для початку (а справа була ще до мови С і qsort) Я просто зробив звичайну бульбашкову сортування, алгоритм зі складністю
O(n2). Після первинного тестування я згодував йому трохи реальних даних. Через десять хвилин після того, як повідомлення «Обробка звітів »з’явилося на консолі, все ще не було ніяких результатів. Парочка простих перевірок показала, що весь цей час програма займалася сортуванням. Ну що ж, я був покараний за свою лінь. Довелося відкопати довірений алгоритм сортування в купі (n log n), І витратити годину часу, щоб реалізувати його робочу версію в моїй програмі (як ви пам’ятаєте, ще жодного qsort не було і в помині). Закінчивши з реалізацією, я знову запустив тест. Через сім хвилин після початку фази обробки звітів результатів ще не було. Перевірки розкрили дещо цікаве: тепер програма велику частину часу була зайнята виконанням еквівалента функції strcmp, Порівнюючи рядка. Вирішуючи проблеми з Про, Я просто проігнорував З. Тому спочатку я зробив окрему сортування таблиці символів, що представляють імена, і асоціював кожен запис з цілим числом. Потім, коли потрібно було сортувати підструктури, я вже мав справу з простими цілочисельними ідентифікаторами. Цей прийом зменшив константу
C до того порога, при якому для повної сортування звітів вимагалося менше 30 секунд. Вторинний ефект, але дуже значущий.


Деякі інструменти профілювання вимірюють тільки час процесів, проведене в режимі користувача виконання, а час виконання в режимі ядра не враховують. Це може замаскувати ту ударну навантаження, яке додаток перекладає на плечі процесів в режимі ядра. Приміром, одного разу мені довелося розбиратися з програмою, продуктивність якої була просто на нулі. У термінах витраченого часу ніяких вузьких місць c допомогою профайлера виявлено не було. Однак, коли я глянув на налагоджувальну інформацію, то побачив що процедура зчитування даних викликалася близько мільйона разів, що не є винятковим при обробці мегабайтів даних, але мене це насторожило. Подивившись на виконання коду при налагодженні, я виявив що кожен раз коли процедура викликалася, вона зверталася до ядра щоб прочитати один байт з файлу! Замінивши така поведінка на роботу з 8-кілобайтний буфером, я отримав 30-кратне збільшення продуктивності. Висновок з цього такий: час виконання в режимі ядра має значення. Не випадково графічний інтерфейс користувача починаючи з NT 4.0 більше не є для користувача процесом, а інтегрований в ядро. Процеси ядра диктують рівень продуктивності.


Тому відповідь на питання «що оптимізувати? »дуже простий: оптимізувати треба те, що забирає надто багато часу. У той же час локальні оптимізації, ігнорують загальні проблеми з продуктивністю абсолютно марні. І ефекти першого порядку (наприклад, час виконання, зайняте розподільником) можуть бути побічні. Сім разів відміряй – один раз відріж.


Процвітаюча життя – краще помста


Взагалі-то це просто невеличкий відступ, трохи приправлене особистими спогадами. Можете відразу переходити до наступної частини, якщо не хочете читати це. Я вас попередив.


Колись, за часів зародження мови C, Його розподільник пам’яті був найслабкішим з існуючих. Це був алгоритм «перший-ліпший”, тобто він працював наступним чином: розподільник переглядав всі вузли в списку блоків пам’яті, і перший же ліпший вільний блок, який був не менше потрібного розміру, розбивався на дві частини – одна поверталася за запитом, друга (загальний розмір блоку мінус запитаний розмір) поверталася в список вільних вузлів. «Переваги» цього очевидні – дуже низька швидкість роботи і дика фрагментація пам’яті. В дійсності це гірше, ніж ви можете собі уявити. При виділенні пам’яті доводилося пробігати весь список блоків, ігноруючи вже виділені. Тому при збільшенні числа блоків продуктивність падала, а блоки ставали все менше і були непридатні до використання. Вони віднімали час без всякої реальної користі.


Я якось працював в CMU по річному контрактом. І моє перше враження від використання середовища Unix виражалося в бажанні піти до когось з оточуючих і запитати – «як ви взагалі можете жити при такому розкладі речей? » Технології ПО в 1990 були в точності тими ж, що і за десять років до цього, коли я закінчував CMU, за винятком того, що в сучасному випадку компілятор не працював (він генерував неправильний код навіть для найпростіших конструкцій), відладчик не працював, відстеження викликів (яке цілком складалося з шістнадцятиричних адрес без жодних символів) було марним, лінковщік НЕ працював, і не було нічого навіть віддалено схожого на відповідну систему документування. Не приймаючи до уваги ці дрібниці, я очікував хоча б нормальної користувача середовища. Використовуючи до цього Microsoft C разом з CodeView, і навіть ранні версії середовища Visual C, я встановив для себе досить високі стандарти щодо інструментарію, від яких Unix (особливо в то час) відстала дуже далеко. На цілі милі. І пару раз я щиросердно висловився на цю тему.


В один із днів ми обговорювали якийсь алгоритм, який вимагав розподілу пам’яті. Я був переконаний, що це рішення є неприйнятним, оскільки розподіл пам’яті обійшлося б надто дорого. Я вимовив щось на кшталт «ну звичайно, якщо ви будете використовувати цей тупоголовий розподільник пам’яті з Unix, то ви приречені на проблеми з ресурсами. Нормальний розподільник зняв би всі питання. ». Одна людина з присутніх на обговоренні відразу ж накинувся на мене: «Мені неприємно чути, як ви опускаєте Unix. І взагалі, що ви знаєте про розподільниках пам’яті? ». На що моя відповідь була – «затримаєтеся на цій думці, я зараз повернуся ». Я сходив до свого кабінету, де лежала копія книги IDL, приніс її з собою назад, і відкрив главу «Розподіл пам’яті». «Бачите це?» – «Так». «Як називається ця глава? »-« Розподіл пам’яті ». Я закрив книгу і вказав на обкладинку – «Це ім’я вам знайоме?» – «Так, це ваше ім’я». «Відмінно. Я написав цю главу, в якій розповідається про розробку високопродуктивного, мінімально фрагментуються розподільника пам’яті. Отже, ви запитували, що я знаю про розподільниках пам’яті? Взагалі-то, я написав на цю тему книгу».


Більше ніхто не накидався на мене, коли я опускав Unix.


В якості невеликого зауваження. Розподільник пам’яті в NT працює вельми схожим чином з тим, що я описав в книзі IDL, і заснований на алгоритмі «швидкого збіги », розробленому Чаком Вейнстоком для його докторської в CMU близько 1974 року.


Коли не потрібно оптимізувати


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


Потрібно брати до уваги людський фактор. Комп’ютерна миша знаходиться приблизно в 60 сантиметрах від вуха. Звук поширюється зі швидкістю близько 330 м / с. Це означає, що звук від клацання мишею або натискання клавіші доходить до вуха за 2 мілісекунди. Ланцюжок нервових клітин від мозку до кінчиків пальців в довжину складає близько 90 сантиметрів. Поширення сигналу по нервових клітин має швидкість близько 100 м / с, тобто факт натискання кнопки миші або клавіатури приймається мозком приблизно через 10 мілісекунд. Плюс, затримка сприйняття сигналу в мозку може скласти від 50 до 250 мілісекунд.


Як багато інструкцій процесор Pentium встигне виконати за 2, або за 10, або за 100 мілісекунд? За 2 мілісекунди 500 MHz процесор виконує 1000000 тактів, так що за цей час ви зможете виконати багато інструкцій. Навіть на такому непотребі як 120 MHz Pentium відчутною затримки при обробці графічних елементів управління немає.


Однак цей факт не завадив Microsoft повністю проігнорувати об’єктну модель для обробки подій, якщо ви звертаєтеся до процедури
CWnd::OnWhatever(…), Замість того щоб безпосередньо викликати
DefWindowProc з потрібними параметрами, MFC повторно використовує параметри останнього повідомлення для виклику ::DefWindowProc. Метою цього було «Зменшення розміру бібліотеки MFC», начебто пара зайвих рядків коду в масивної бібліотеці може мати значення! Навіть я можу збагнути, як замість
CWnd::OnWhatever(…) можна зробити inline-підстановку для виклику
DefWindowProc.


Оптимізація – ваш ворог


Колись, багато років тому, я працював на великий (16 процесорів) багатопроцесорної системі. Використовувалися спеціальним чином модифіковані мінікомп’ютери PDP-11, в цілому щодо повільні. Ми програмували їх з допомогою Bliss-11, який, як я можу сказати, до сих пір знаходиться в списку світових лідерів серед сильно оптимізують компіляторів (незважаючи на те, що я бачив дійсно вражаючі оптимізації в Microsoft C / C + +). Зробивши кілька вимірів рівня продуктивності, ми виявили, що алгоритм сторінкового доступу являє собою вузьке місце. Тому природним припущенням було перевірити алгоритм на наявність вад. Після аналізу коду людина, відповідальна за цей алгоритм, переписав його, беручи до уваги наші нові побажання щодо продуктивності. Через тиждень у нас вже була нова, більш швидко працююча версія алгоритму.


Між тим, в MIT (Массачусетський технологічний університет), все ще працювала операційна система MULTICS. І вони вказали на серйозну проблему з продуктивністю, яка впиралася в алгоритм сторінкового доступу. Через того, що реалізація алгоритму була виконана на PL/1-подобном мовою, EPL, вони припустили неоптимальність реалізації через використання мови високого рівня. Тому було докладено зусиль для переписування алгоритму цілком на асемблері. Через рік, коли вся система була готова і вийшла в промислову експлуатацію, втрати продуктивності склали 5%. Після детальної інспекції з’ясувався факт наявності помилки в одному з фундаментальних алгоритмів. З використанням мови EPL, заміна старої версії алгоритму виправленої була виконана через пару тижнів. Висновок: не оптимізуйте щось, що не представляє собою проблему. Для початку постарайтеся цю проблему виявити. І тільки після цього можна думати про оптимізацію. В іншому випадку вся ваша оптимізація буде марною тратою часу і може навіть погіршити продуктивність.


У компіляторі Bliss атрибут змінної register був рівнозначний наказом для компілятора – «Ти дійсно збережеш цю змінну в регістрі процесора ». У мові С такий атрибут означає зовсім інше – «Я б хотів, Щоб ти зберіг цю змінну в регістрі процесора ». Безліч програмістів вважають, що вони повинні розміщувати свої змінні в регістрах для отримання оптимального по продуктивності коду. Компілятор Bliss дійсно дуже хороший, і реалізує дуже складну схему розподілу регістрів під змінні, і при відсутності вказівок від програміста володіє свободою вибору при розміщенні змінної в регістрі, якщо така дія покращує продуктивність. Проте явне вказівку на збереження змінної в регістрі процесора робила цей регістр недоступним для більш загальних обчислень, зокрема при доступі до структур даних. Після кількох сумлінних експериментів було виявлено, що в переважній більшості випадків додавання атрибута register до змінної породжувало значно гірший код, ніж якщо б компілятор сам займався змінними і регістрами. Багатогодинні зусилля при розробці якого-небудь вкладеного циклу можуть призвести до невеликого поліпшення продуктивності, але в цілому було абсолютно ясно, що без вивчення згенерованого машинного коду та серії відкаліброваних експериментів будь спроба оптимізації веде до гіршого коду.


Якщо ви чули про еталонних тестах продуктивності групи SPEC, то напевно маєте уявлення про те, як ці тести обіграються. Зокрема, IBM написала програму, яка бере базову програму на FORTRAN (яка, до Приміром може виконувати один з еталонних тестів – начебто множення матриць), і перетворює її з урахуванням оптимізації для архітектури кешу системи, на якій програма буде працювати. Невелика кількість параметрів описує все стратегії кешування для модельної лінії RISC 6000. Вихідна програма показує на якийсь системі результат в 45 очок за класифікацією SPEC. Після відповідної «оптимізує» модифікації та ж програма показує результат в 900 очок. Це 20-кратне збільшення продуктивності засновано цілком на сторонніх ефекти четвертого порядку – стратегії кешування для приватної архітектури. Якщо ви займаєтеся перетворенням зображень, особливо зображень великих розмірів, то знання про кешуванні (нехай навіть машинно-незалежне) може принести вам приріст продуктивності на порядок.


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


Можливо, найкращий приклад чисто програмістської дурниці при «Оптимізації» я бачив, коли займався переносом великої бібліотеки в дослідному проекті. Уявіть собі, що це був перенесення з 16-бітної платформи на 32-бітну (насправді це було 18-біт на 36-біт портування, і мова була зовсім не С, але це неважливо – страхітливий код можна написати на будь-якій мові, і я бачив програмістів на С, роблять ті ж помилки). В основному все працювало, але іноді виникала дивна проблема, виявлялася при рідкісному збігу умов, і приводила ця проблема до краху програми. Я почав розбиратися. Пам’ять у купі була пошкоджена. Коли я виявив, як це відбувалося, виявилося, що пам’ять у купі ушкоджувалася при використанні невірного покажчика, який приводив до затирання випадкового місця у цій же купі. О’кей, а як цей покажчик став зіпсованим? Чотири рівні вкладеності вниз при використанні покажчиків, і через 12 безперервних годин налагодження я знайшов джерело проблеми. Але чому це сталося? Ще через 5:00 я виявив що програміст написав конструктор для структури даних, схожої на
struct, у вигляді { char* p1; char* p2; } де покажчики були спочатку 16-бітними, а потім стали 32-бітними. Коли я подивився на код ініціалізації, замість очікуваних конструкцій на зразок something->p1 = NULL;
something->p2 = NULL;
я побачив код еквівалентний
(*(DWORD*)&something.p1) = 0! На очній ставці програміст намагався виправдатися тим, що він зміг обнулити два покажчики однієї машинної інструкцією (Хоча це був не x86-комп’ютер, а мейнфрейм), і підносив цю дію як розумну оптимізацію. Звичайно, коли покажчики стали 32-бітними, така оптимізація приводила до того, що обнуляється тільки один з двох покажчиків, а другий залишався заповненим якимось випадковим значенням. Я помітив що така оптимізація спрацьовувала тільки один раз, при створенні об’єкта, і що середнє додаток, використовувала цю бібліотеку створювало в середньому шість таких об’єктів, і що я витратив у попередній день 17 годин особистого часу і 6 годин машинного часу на налагодження, і що якщо б програма не містила помилки і запускалася б безперервно відразу після свого завершення протягом 14 годин, то час, заощаджений цієї «розумною оптимізацією» просто розсіюється як пил! Пару років тому цей програміст все ще викидав подібні трюки – є люди, які ніколи нічого не вчаться.


Висновки


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


Важко впоратися з такими наслідками! Тепер ви розумієте, в чому сенс заголовка статті?


Схожі статті:


Сподобалася стаття? Ви можете залишити відгук або підписатися на RSS , щоб автоматично отримувати інформацію про нові статтях.

Коментарів поки що немає.

Ваш отзыв

Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

*