Чому моя програма витрачає 24% свого життя на нульову перевірку?


104

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

        public ScTreeNode GetNodeForState(int rootIndex, float[] inputs)
        {
0.2%        ScTreeNode node = RootNodes[rootIndex].TreeNode;

24.6%       while (node.BranchData != null)
            {
0.2%            BranchNodeData b = node.BranchData;
0.5%            node = b.Child2;
12.8%           if (inputs[b.SplitInputIndex] <= b.SplitValue)
0.8%                node = b.Child1;
            }

0.4%        return node;
        }

BranchData - це поле, а не властивість. Я зробив це для того, щоб не допустити ризику його не розкреслити.

Клас BranchNodeData такий:

public sealed class BranchNodeData
{
    /// <summary>
    /// The index of the data item in the input array on which we need to split
    /// </summary>
    internal int SplitInputIndex = 0;

    /// <summary>
    /// The value that we should split on
    /// </summary>
    internal float SplitValue = 0;

    /// <summary>
    /// The nodes children
    /// </summary>
    internal ScTreeNode Child1;
    internal ScTreeNode Child2;
}

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

Я спробував:

  • Відокремлення Null чек від часу - це хіт Null check.
  • Додавання булевого поля до об'єкта та перевірка проти цього, це не мало значення. Не має значення, з чим порівнюється, це саме порівняння.

Це питання прогнозування галузі? Якщо так, то що я можу з цим зробити? Якщо щось?

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

.method public hidebysig
instance class OptimalTreeSearch.ScTreeNode GetNodeForState (
    int32 rootIndex,
    float32[] inputs
) cil managed
{
    // Method begins at RVA 0x2dc8
    // Code size 67 (0x43)
    .maxstack 2
    .locals init (
        [0] class OptimalTreeSearch.ScTreeNode node,
        [1] class OptimalTreeSearch.BranchNodeData b
    )

    IL_0000: ldarg.0
    IL_0001: ldfld class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode> OptimalTreeSearch.ScSearchTree::RootNodes
    IL_0006: ldarg.1
    IL_0007: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode>::get_Item(int32)
    IL_000c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.ScRootNode::TreeNode
    IL_0011: stloc.0
    IL_0012: br.s IL_0039
    // loop start (head: IL_0039)
        IL_0014: ldloc.0
        IL_0015: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_001a: stloc.1
        IL_001b: ldloc.1
        IL_001c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child2
        IL_0021: stloc.0
        IL_0022: ldarg.2
        IL_0023: ldloc.1
        IL_0024: ldfld int32 OptimalTreeSearch.BranchNodeData::SplitInputIndex
        IL_0029: ldelem.r4
        IL_002a: ldloc.1
        IL_002b: ldfld float32 OptimalTreeSearch.BranchNodeData::SplitValue
        IL_0030: bgt.un.s IL_0039

        IL_0032: ldloc.1
        IL_0033: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child1
        IL_0038: stloc.0

        IL_0039: ldloc.0
        IL_003a: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_003f: brtrue.s IL_0014
    // end loop

    IL_0041: ldloc.0
    IL_0042: ret
} // end of method ScSearchTree::GetNodeForState

Редагувати: я вирішив зробити тест прогнозування гілки, я додав ідентичний, якщо протягом певного часу, так що у нас є

while (node.BranchData != null)

і

if (node.BranchData != null)

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

Ще одна редакція

Вищенаведений результат також виникне, якщо node.BranchData довелося завантажувати з оперативної пам’яті на час перевірки - він буде кешований для оператора if.


Це моє третє запитання на подібну тему. Цього разу я зосереджуюсь на одному рядку коду. Мої інші питання з цього приводу:


3
Будь ласка, покажіть реалізацію BranchNodeоб’єкта. Спробуйте замінити node.BranchData != null ReferenceEquals(node.BranchData, null). Чи має це значення?
Даніель Гільгарт

4
Ви впевнені, що 24% - це не заява на той час, а не умова, яка виражається в тій частині заяви
Rune FS

2
Ще один тест: спробувати повторно написати час циклу , як це: while(true) { /* current body */ if(node.BranchData == null) return node; }. Це щось змінює?
Даніель Гільгарт

2
Трохи оптимізація полягала б у наступному: while(true) { BranchNodeData b = node.BranchData; if(ReferenceEquals(b, null)) return node; node = b.Child2; if (inputs[b.SplitInputIndex] <= b.SplitValue) node = b.Child1; }це отримає node. BranchDataлише один раз.
Даніель Гілгарт

2
Будь ласка, додайте кількість разів, коли два рядки з найбільшою витратою часу виконуються загалом.
Даніель Гілгарт

Відповіді:


180

Дерево масивне

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

Процесори мають протимір цієї проблеми, вони використовують кеші , буфери, які зберігають копію байтів в оперативній пам'яті. Важливим є кеш L1 , як правило, 16 кілобайт для даних та 16 кілобайт для інструкцій. Невеликий, що дозволяє йому бути близько до двигуна виконання. Читання байтів з кешу L1 зазвичай займає 2 або 3 циклу процесора. Далі йде кеш L2, більший і повільніше. У висококласних процесорів також є кеш-пам’ятник L3, ще більший і повільний. У міру вдосконалення технологічних процесів ці буфери займають менше місця і автоматично стають швидшими, коли вони наближаються до ядра, що є великою причиною того, що нові процесори краще і як їм вдається використовувати постійно зростаючу кількість транзисторів.

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

Деревні структури - це проблема, вони не є кешованими. Їх вузли, як правило, розкидані по всьому адресному простору. Найшвидший спосіб отримати доступ до пам'яті - це читання з послідовних адрес. Одиниця пам’яті для кешу L1 становить 64 байти. Або іншими словами, як тільки процесор прочитає один байт, наступні 63 будуть дуже швидкими, оскільки вони будуть присутні в кеші.

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

Тому, швидше за все, ваш заява while () страждає від зупинок процесора, оскільки він перенаправляє вказівник на доступ до поля BranchData. Наступне твердження є дуже дешевим, оскільки оператор while () вже зробив важке зняття значення з пам'яті. Призначення локальної змінної є дешевим, процесор використовує буфер для запису.

Інакше непросту проблему вирішити, сплющення дерева в масиви, швидше за все, буде непрактичним. Принаймні, тому що ти зазвичай не можеш передбачити, в якому порядку будуть відвідуватися вузли дерева. Червоно-чорне дерево може допомогти, не зрозуміло з питання. Тож простий висновок зробити такий, що він вже працює так швидко, на який можна сподіватися. І якщо вам це потрібно швидше, то вам знадобиться краще обладнання з більш швидкою шиною пам'яті. Цього року DDR4 перейде в мейнстрім.


1
Може бути. Вони, ймовірно, вже суміжні в пам'яті, а значить, і в кеші, оскільки ви виділяли один за одним. Інакше алгоритм ущільнення GC купує непередбачуваний вплив на це. Найкраще, щоб я не здогадувався про це, міряйте, щоб ви знали факт.
Ганс Пасант

11
Нитки не вирішують цю проблему. Дає більше ядер, у вас все ще є лише одна шина пам'яті.
Ганс Пасант

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

4
глибокі пояснення з широким спектром пов'язаної інформації, як завжди. +1
Тигран

1
Якщо ви знаєте шаблон доступу до дерева, і він відповідає правилу 80/20 (80% доступу завжди знаходиться на тих самих 20% вузлів), саморегулююче дерево, подібне до дерева splay, також може виявитися швидше. en.wikipedia.org/wiki/Splay_tree
Єнс Тіммерман

10

Щоб доповнити чудову відповідь Ганса щодо ефектів кешу пам'яті, я додаю обговорення віртуальної пам'яті до перекладу фізичної пам'яті та ефектів NUMA.

За допомогою комп'ютера з віртуальною пам'яттю (весь поточний комп'ютер) при доступі до пам'яті кожна адреса віртуальної пам'яті повинна бути переведена на фізичну адресу пам'яті. Це робиться апаратним забезпеченням управління пам'яттю за допомогою таблиці перекладу. Ця таблиця управляється операційною системою для кожного процесу, і вона сама зберігається в оперативній пам'яті. Для кожної сторінки віртуальної пам'яті в цій таблиці перекладу є запис, який відображає віртуальну на фізичну сторінку. Запам’ятайте дискусію Ганса про дорогі доступ до пам’яті: якщо кожен віртуальний для фізичного перекладу потребує пошуку пам’яті, весь доступ до пам’яті коштуватиме вдвічі дорожче. Рішення полягає в тому, щоб мати кеш для таблиці перекладу, яка називається буфером перекладу перегляду(TLB коротко). TLB не великі (12 - 4096 записів), а типовий розмір сторінки для архітектури x86-64 становить лише 4 Кб, це означає, що з хітів TLB є щонайменше 16 Мб (це, мабуть, навіть менше, ніж Sandy Міст, розмір TLB - 512 предметів ). Щоб зменшити кількість пропусків TLB, ви можете змусити операційну систему та програму спільно використовувати більший розмір сторінки, як 2 Мб, що призводить до набагато більшого простору пам’яті, доступного за допомогою звернень TLB. На цій сторінці пояснено, як використовувати великі сторінки з Java, що може значно прискорити доступ до пам'яті .

Якщо на вашому комп'ютері багато розеток, це, мабуть, архітектура NUMA . NUMA означає неоднорідний доступ до пам'яті. У цих архітектурах деякі доступні пам'яті коштують дорожче, ніж інші. Наприклад, із 2-гніздовим комп'ютером з 32 ГБ оперативної пам’яті, кожен сокет, ймовірно, має 16 ГБ оперативної пам’яті. На цьому прикладі комп'ютера доступ до локальної пам’яті дешевший, ніж доступ до пам’яті іншого сокета (віддалений доступ на 20–100% повільніше, можливо навіть більше). Якщо на такому комп’ютері ваше дерево використовує 20 ГБ оперативної пам’яті, принаймні 4 ГБ ваших даних знаходиться на іншому вузлі NUMA, а якщо доступ на віддалену пам’ять на 50% повільніше, доступ до NUMA уповільнює доступ до вашої пам’яті на 10%. Крім того, якщо у вас є лише вільна пам'ять на одному вузлі NUMA, всі процеси, що потребують пам'яті на голодному вузлі, будуть виділятися пам'яттю з іншого вузла, доступ до якого є дорожчим. Навіть найгірше, що операційна система може подумати, що це гарна ідея поміняти частину пам'яті голодного вузла,що призведе до ще більш дорогих доступів до пам'яті . Це пояснюється більш докладно в проблемі "swap insanity" MySQL та наслідках архітектури NUMA, де деякі рішення даються для Linux (розповсюдження доступу до пам'яті на всі вузли NUMA, кусання кулі на віддалений доступ NUMA, щоб уникнути заміни). Я також можу подумати про виділення більшої кількості оперативної пам’яті на розетку (24 та 8 Гб замість 16 та 16 ГБ) і переконатися, що ваша програма розкладена на більшому вузлі NUMA, але для цього потрібен фізичний доступ до комп'ютера та викрутки ;-) .


4

Це не відповідь сама по собі, а наголос на тому, що написав Ганс Пасант про затримки в системі пам'яті.

Дійсно високоефективне програмне забезпечення - наприклад, комп'ютерні ігри - не тільки написане для впровадження самої гри, але й адаптоване таким чином, що структури коду та даних використовують максимум кеш-систем та пам'яті, тобто трактують їх як обмежений ресурс. Коли я маю справу з питаннями кешу, я, як правило, припускаю, що L1 подаватиметься за 3 цикли, якщо дані там є. Якщо це не так, і я повинен перейти на L2, я припускаю 10 циклів. Для циклів L3 30 і для пам'яті 100 оперативної пам'яті.

Існує додаткова дія, пов’язана з пам’яттю, яка - якщо вам потрібно використовувати її - накладає ще більшу штрафну санкцію, і це блокування шини. Блокування шини називають критичними розділами, якщо ви використовуєте функцію Windows NT. Якщо ви використовуєте домашній сорт, ви можете назвати його прядивом. Незалежно від назви, яку він синхронізує, до найповільнішого пристрою керування шиною в системі до встановлення блокування. Найповільнішим пристроєм для управління шиною може бути класична 32-бітна PCI-карта, підключена при 33 МГц. 33 МГц - сота частоти типового процесора x86 (@ 3,3 ГГц). Я припускаю, що не менше 300 циклів для завершення блокування шини, але я знаю, що це може зайняти багато разів, тому якщо я побачу 3000 циклів, я не здивуюсь.

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

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