Чи можна порівняти зразки невеликих кодів у C #, покращити цю реалізацію?


104

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

Досить часто я бачу коментарі, що тест-код не враховує джитінг або сміттєзбірник.

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

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Використання:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Чи має ця реалізація недоліки? Чи достатньо добре, щоб показати, що реалізація X швидша, ніж впровадження Y за Z ітерації? Чи можете ви придумати будь-які способи, які б ви покращили?

EDIT Цілком очевидно, що кращим є підхід на основі часу (на відміну від ітерацій), чи має хто-небудь реалізація, де перевірка часу не впливає на ефективність?


Дивіться також BenchmarkDotNet .
Бен Хатчісон,

Відповіді:


95

Ось модифікована функція: за рекомендацією спільноти, не соромтесь вносити зміни до цього свого вікі спільноти.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Переконайтеся, що ви компілюєте в Release з увімкненими оптимізаціями та запускаєте тести поза Visual Studio . Ця остання частина важлива, оскільки JIT вказує свої оптимізації за допомогою відладчика, навіть у режимі випуску.


Ви можете розгортати цикл кілька разів, наприклад, 10, щоб мінімізувати накладення циклу.
Майк Данлаве

2
Я щойно оновив, щоб використовувати Stopwatch.StartNew. Не є функціональною зміною, але зберігає один рядок коду.
ЛукаH

1
@Luke, чудова зміна (я б хотів, щоб я міг поставити +1). @Mike я не впевнений, я підозрюю, що накладні витрати на virtualcall будуть набагато вищими, ніж порівняння та призначення, тому різниця в продуктивності буде незначною
Сем Сафрон

Я б запропонував вам передати кількість ітерацій в Action і створити там цикл (можливо - навіть розкручений). Якщо ви вимірюєте відносно короткий час роботи, це єдиний варіант. І я вважаю за краще бачити зворотну метрику - наприклад, кількість проходів / сек.
Олексій Якунін

2
Що ви думаєте про показ середнього часу. Приблизно так: Console.WriteLine ("Середній час минув {0} мс", watch.ElapsedMilliseconds / iterations);
рудиметр

22

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

Якщо ви хочете переконатися, що фіналізація завершена перед початком тестів, вам, можливо, захочеться зателефонувати GC.WaitForPendingFinalizers, який буде заблокований, поки не буде очищена черга на завершення:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

10
Чому GC.Collect()ще раз?
colinfang

7
@colinfang Оскільки об'єкти, які "доопрацьовуються", не є GC'ed фіналізатором. Тож друге Collectє для того, щоб переконатися, що «доопрацьовані» об’єкти також зібрані.
MAV

15

Якщо ви хочете вийняти взаємодію GC з рівняння, ви можете запустити «розігрівати» дзвінок після виклику GC.Collect, не раніше. Таким чином, ви знаєте .NET вже буде мати достатню кількість пам'яті, виділеної з ОС для робочого набору вашої функції.

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

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


1
хороші моменти, чи маєте ви на увазі впровадження часу?
Сем Шафран

6

Я б взагалі не передавав делегата:

  1. Виклик делегата - це віртуальний метод виклику. Не дешево: ~ 25% найменшого розміщення пам'яті у .NET. Якщо вас цікавлять деталі, дивіться, наприклад, це посилання .
  2. Анонімні делегати можуть призвести до використання закриття, про що ви навіть не помітите. Знову ж таки, доступ до полів закриття помітніший, ніж наприклад, доступ до змінної на стеку.

Приклад коду, що призводить до використання закриття:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Якщо ви не знаєте про закриття, перегляньте цей метод у .NET Reflector.


Цікаві моменти, але як би ви створили метод повторного використання профілю (), якщо ви не передасте делегата? Чи існують інші способи передачі довільного коду методу?
Еш

1
Ми використовуємо "using (новий вимірювання (...)) {... вимірюваний код ...}". Таким чином, ми отримуємо об'єкт Measurement, що реалізує IDisposable замість передачі делегата. Дивіться code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Алекс Якунін

Це не призведе до проблем із закриттям.
Олексій Якунін

3
@AlexYakunin: ваше посилання, здається, порушено. Чи можете ви включити код для класу Measurement у свою відповідь? Я підозрюю, що незалежно від того, як ви його реалізуєте, ви не зможете запустити код, який буде профільовано кілька разів, за допомогою цього підходу, що дозволяє використовувати його. Однак це дійсно дуже корисно в ситуаціях, коли ви хочете виміряти ефективність різних частин складної (переплетеної) програми, якщо ви пам’ятаєте, що вимірювання можуть бути неточними та непослідовними, коли проводитись у різний час. Я використовую той самий підхід у більшості своїх проектів.
ShdNx

1
Вимога провести тест на ефективність кілька разів дуже важлива (розминка + кілька вимірювань), тому я перейшов на підхід і з делегатом. Більше того, якщо ви не використовуєте закриття, виклик делегата буде швидшим, ніж виклик методу інтерфейсу у випадку з IDisposable.
Олексій Якунін

6

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

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


1
Значущим є один із тих термінів, який дійсно завантажений. інколи реалізація, яка на 20% швидша, є значною, іноді вона повинна бути в 100 разів швидшою, щоб бути значною. Погодьтеся з вами на ясність дивіться: stackoverflow.com/questions/1018407/…
Сем Шафран,

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

5

Я б подзвонив func()кілька разів на розминку, а не лише одну.


1
Намір полягав у тому, щоб забезпечити компіляцію jit, яку перевагу ви отримуєте від виклику функцій кілька разів перед вимірюванням?
Сем Шафран

3
Щоб дати можливість СІТ покращити свої перші результати.
Олексій Романов

1
.NET JIT не покращує його результати з часом (як це робить Java). Він лише перетворює метод з IL в Асамблею лише один раз, на перший виклик.
Метт Уоррен

4

Пропозиції щодо вдосконалення

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

  2. Вимірювання частин коду самостійно (щоб точно побачити, де знаходиться вузьке місце).

  3. Порівнюючи різні версії / компоненти / шматки коду (У першому реченні ви говорите "... порівняльний аналіз невеликих шматочків коду, щоб побачити, яка реалізація найшвидша.").

Щодо №1:

  • Щоб визначити, чи налагоджено налагоджувач, прочитайте властивість System.Diagnostics.Debugger.IsAttached(Не забудьте також обробляти той випадок, коли налагоджувач спочатку не приєднаний, а додається через деякий час).

  • Щоб виявити, чи оптимізація jit відключена, прочитайте властивість DebuggableAttribute.IsJITOptimizerDisabledвідповідних зборів:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }

Щодо № 2:

Це можна зробити багатьма способами. Один із способів - дозволити надання декількох делегатів, а потім виміряти цих делегатів окремо.

Щодо №3:

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

Один із способів зробити це - повернути результат еталону як сильно набраний об'єкт, який легко може бути використаний у різних контекстах.


Etimo.Benchmarks

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

Це приклад виведення, де порівнюються два компоненти і результати записуються на консоль. У цьому випадку два порівняні компоненти називаються "KeyedCollection" і "MultiplyIndexedKeyedCollection":

Etimo.Benchmarks - Вихід зразка консолі

Є пакет NuGet , зразок пакету NuGet, а вихідний код доступний у GitHub . Також є публікація в блозі .

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


1

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


це виконується до вимірювання
Сем Шафрон,

1

Залежно від коду, який ви оцінюєте, і платформи, на якій він працює, вам може знадобитися врахувати, як вирівнювання коду впливає на продуктивність . Для цього, ймовірно, знадобиться зовнішня обгортка, яка виконувала тест кілька разів (в окремих доменних програмах чи процесах?); Деякі з них вперше викликали "код підкладки", щоб змусити його складати JIT, щоб змусити код бути орієнтир, щоб вирівняти по-різному. Повний результат тесту дасть найкращі та найгірші строки для різних вирівнювань коду.


1

Якщо ви намагаєтесь усунути вплив збору сміття з еталону, чи варто його встановити GCSettings.LatencyMode?

Якщо ні, і ви хочете, щоб вплив створеного сміття funcбув частиною еталону, то чи не слід також змушувати збирання в кінці тесту (всередині таймера)?


0

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

Ще одна відповідь дає нормальний спосіб вимірювання базових показників.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.