Почему моя производительность замедляется, я перемещаю методы в базовый класс? - PullRequest
18 голосов
/ 11 марта 2010

Я пишу различные реализации неизменяемых двоичных деревьев в C #, и я хотел, чтобы мои деревья наследовали некоторые общие методы из базового класса.

К сожалению, классы, производные от базового класса, ужасно медленные. Непроизводные классы работают адекватно. Вот две почти идентичные реализации дерева AVL для демонстрации:

Два дерева имеют точно такой же код , но я переместил метод DerivedAvlTree.Insert в базовый класс. Вот тестовое приложение:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Juliet.Collections.Immutable;

namespace ConsoleApplication1
{
    class Program
    {
        const int VALUE_COUNT = 5000;

        static void Main(string[] args)
        {
            var avlTreeTimes = TimeIt(TestAvlTree);
            var derivedAvlTreeTimes = TimeIt(TestDerivedAvlTree);

            Console.WriteLine("avlTreeTimes: {0}, derivedAvlTreeTimes: {1}", avlTreeTimes, derivedAvlTreeTimes);
        }

        static double TimeIt(Func<int, int> f)
        {
            var seeds = new int[] { 314159265, 271828183, 231406926, 141421356, 161803399, 266514414, 15485867, 122949829, 198491329, 42 };

            var times = new List<double>();

            foreach (int seed in seeds)
            {
                var sw = Stopwatch.StartNew();
                f(seed);
                sw.Stop();
                times.Add(sw.Elapsed.TotalMilliseconds);
            }

            // throwing away top and bottom results
            times.Sort();
            times.RemoveAt(0);
            times.RemoveAt(times.Count - 1);
            return times.Average();
        }

        static int TestAvlTree(int seed)
        {
            var rnd = new System.Random(seed);

            var avlTree = AvlTree<double>.Create((x, y) => x.CompareTo(y));
            for (int i = 0; i < VALUE_COUNT; i++)
            {
                avlTree = avlTree.Insert(rnd.NextDouble());
            }

            return avlTree.Count;
        }

        static int TestDerivedAvlTree(int seed)
        {
            var rnd = new System.Random(seed);

            var avlTree2 = DerivedAvlTree<double>.Create((x, y) => x.CompareTo(y));
            for (int i = 0; i < VALUE_COUNT; i++)
            {
                avlTree2 = avlTree2.Insert(rnd.NextDouble());
            }

            return avlTree2.Count;
        }
    }
}
  • AvlTree: вставка 5000 элементов за 121 мс
  • DerivedAvlTree: вставка 5000 элементов за 2182 мс

Мой профилировщик показывает, что программа тратит слишком много времени на BaseBinaryTree.Insert. Любой желающий может увидеть файл журнала EQATEC , который я создал с помощью приведенного выше кода (вам понадобится EQATEC profiler , чтобы разобраться в файле).

Я действительно хочу использовать общий базовый класс для всех моих двоичных деревьев, но я не могу этого сделать, если производительность пострадает.

Что заставляет мое DerivedAvlTree работать так плохо и что я могу сделать, чтобы это исправить?

Ответы [ 3 ]

17 голосов
/ 11 марта 2010

Примечание - теперь есть «чистое» решение, поэтому перейдите к окончательному редактированию, если вам нужна только версия, которая работает быстро и не заботится обо всей детективной работе.

Кажется, что нет разницы между прямыми и виртуальными вызовами, которые вызывают замедление. Это как-то связано с этими делегатами; Я не могу точно объяснить, что это такое, но взгляд на сгенерированный IL показывает множество кэшированных делегатов, которые, я думаю, могут не использоваться в версии базового класса. Но сам IL, кажется, не сильно отличается между двумя версиями, что заставляет меня полагать, что сам джиттер частично ответственен.

Взгляните на этот рефакторинг, который сокращает время работы примерно на 60%:

public virtual TreeType Insert(T value)
{
    Func<TreeType, T, TreeType, TreeType> nodeFunc = (l, x, r) =>
    {
        int compare = this.Comparer(value, x);
        if (compare < 0) { return CreateNode(l.Insert(value), x, r); }
        else if (compare > 0) { return CreateNode(l, x, r.Insert(value)); }
        return Self();
    };
    return Insert<TreeType>(value, nodeFunc);
}

private TreeType Insert<U>(T value, 
    Func<TreeType, T, TreeType, TreeType> nodeFunc)
{
    return this.Match<TreeType>(
        () => CreateNode(Self(), value, Self()),
        nodeFunc);
}

Это должно (и, очевидно, делает) гарантировать, что делегат вставки создается только один раз для каждой вставки - он не создается при каждой рекурсии. На моей машине это сокращает время работы с 350 мс до 120 мс (в отличие от этого, одноклассная версия работает примерно за 30 мс, так что это еще далеко не так, как должно быть).

Но вот где это становится еще более странным - после попытки вышеупомянутого рефакторинга, я подумал, хм, может быть, это все еще медленно, потому что я сделал только половину работы. Поэтому я попытался материализовать и первого делегата:

public virtual TreeType Insert(T value)
{
    Func<TreeType> nilFunc = () => CreateNode(Self(), value, Self());
    Func<TreeType, T, TreeType, TreeType> nodeFunc = (l, x, r) =>
    {
        int compare = this.Comparer(value, x);
        if (compare < 0) { return CreateNode(l.Insert(value), x, r); }
        else if (compare > 0) { return CreateNode(l, x, r.Insert(value)); }
        return Self();
    };
    return Insert<TreeType>(value, nilFunc, nodeFunc);
}

private TreeType Insert<U>(T value, Func<TreeType> nilFunc,
    Func<TreeType, T, TreeType, TreeType> nodeFunc)
{
    return this.Match<TreeType>(nilFunc, nodeFunc);
}

И угадайте, что ... это снова сделало медленнее ! В этой версии на моем компьютере этот запуск занял чуть более 250 мс.

Это не поддается никаким логическим объяснениям, которые могут относить проблему к скомпилированному байт-коду, поэтому я подозреваю, что в этом заговоре присутствует дрожание. Я думаю, что первой приведенной выше «оптимизацией» может быть ( ПРЕДУПРЕЖДЕНИЕ - предположение впереди ), позволяющей встроить делегат вставки - это известный факт, что дрожание не может встроить виртуальные вызовы - но есть еще кое-что не будучи встроенным, и вот где я сейчас в тупике.

Моим следующим шагом было бы выборочно отключить встраивание некоторых методов с помощью MethodImplAttribute и посмотреть, какое влияние это оказывает на среду выполнения - это поможет доказать или опровергнуть эту теорию.

Я знаю, что это не полный ответ, но, надеюсь, он, по крайней мере, даст вам возможность поработать, и, возможно, некоторые дальнейшие эксперименты с этим разложением могут дать результаты, близкие по производительности к исходной версии.


Редактировать: Ха, сразу после того, как я представил это, я наткнулся на другую оптимизацию. Если вы добавите этот метод в базовый класс:

private TreeType CreateNilNode(T value)
{
    return CreateNode(Self(), value, Self());
}

Теперь время работы падает до 38 мс, чуть выше первоначальной версии. Это поражает меня, потому что на самом деле ничто не ссылается на этот метод! Закрытый Insert<U> метод все еще идентичен самому первому блоку кода в моем ответе. Я собирался изменить первый аргумент, ссылающийся на метод CreateNilNode, но мне не пришлось этого делать. Либо джиттер видит, что анонимный делегат такой же, как метод CreateNilNode, и разделяет тело (возможно, снова вставляет), либо ... или, я не знаю. Это первый случай, который я когда-либо видел, когда добавление приватного метода и никогда не вызывая его может ускорить программу в 4 раза.

Вам нужно будет проверить это, чтобы убедиться, что я случайно не ввел никаких логических ошибок - уверен, что нет, код почти такой же - но если все проверяется, то вот, пожалуйста, это работает почти так же быстро, как непроизведенный AvlTree.


ДОПОЛНИТЕЛЬНОЕ ОБНОВЛЕНИЕ

Мне удалось придумать версию базовой / производной комбинации, которая на самом деле работает на быстрее , чем версия для одного класса. Взял немного уговоров, но это работает!

Нам нужно создать выделенный инсертор, который может создать всех делегатов только один раз, без необходимости захвата любой переменной. Вместо этого все состояние хранится в полях членов. Поместите это в класс BaseBinaryTree:

protected class Inserter
{
    private TreeType tree;
    private Func<TreeType> nilFunc;
    private Func<TreeType, T, TreeType, TreeType> nodeFunc;
    private T value;

    public Inserter(T value)
    {
        this.nilFunc = () => CreateNode();
        this.nodeFunc = (l, x, r) => PerformMatch(l, x, r);
        this.value = value;
    }

    public TreeType Insert(TreeType parent)
    {
        this.tree = parent;
        return tree.Match<TreeType>(nilFunc, nodeFunc);
    }

    private TreeType CreateNode()
    {
        return tree.CreateNode(tree, value, tree);
    }

    private TreeType PerformMatch(TreeType l, T x, TreeType r)
    {
        int compare = tree.Comparer(value, x);
        if (compare < 0) { return tree.CreateNode(l.Insert(value, this), x, r); }
        else if (compare > 0) { return tree.CreateNode(l, x, r.Insert(value, this)); }
        return tree;
    }
}

Да, да, я знаю, это очень неработоспособно, используя это изменчивое внутреннее состояние tree, но помните, что это не само дерево, это просто одноразовый «работающий» экземпляр. Никто никогда не говорил, что перфект был хорош! Это единственный способ избежать создания нового Inserter для каждого рекурсивного вызова, который в противном случае замедлил бы это из-за всех новых распределений Inserter и его внутренних делегатов.

Теперь замените методы вставки базового класса на:

public TreeType Insert(T value)
{
    return Insert(value, null);
}

protected virtual TreeType Insert(T value, Inserter inserter)
{
    if (inserter == null)
    {
        inserter = new Inserter(value);
    }
    return inserter.Insert(Self());
}

Я сделал публичный Insert метод не виртуальный ; вся реальная работа делегируется защищенному методу, который принимает (или создает свой собственный) экземпляр Inserter. Изменить производный класс достаточно просто, просто замените переопределенный метод Insert следующим:

protected override DerivedAvlTree<T> Insert(T value, Inserter inserter)
{
    return base.Insert(value, inserter).Balance();
}

Вот и все. Теперь запустите это. Это займет почти столько же времени, сколько и AvlTree, обычно на несколько миллисекунд меньше в сборке релиза.

Замедление очевидно из-за некоторой специфической комбинации виртуальных методов, анонимных методов и захвата переменных, которые каким-то образом мешают джиттеру провести важную оптимизацию. Я не настолько уверен, что он больше встроен, возможно, он просто кеширует делегатов, но я думаю, что единственные люди, которые действительно могли бы это уточнить, - это сами джиттеры.

0 голосов
/ 11 марта 2010

Вы работаете под VS IDE, верно? Это займет примерно в 20 раз больше времени, верно?

Оберните цикл вокруг него, чтобы повторить его 10 раз, поэтому длинная версия занимает 20 секунд. Затем, пока он работает, нажмите кнопку «пауза» и посмотрите на стек вызовов. Вы точно увидите, в чем проблема с вероятностью 95%. Если вы не верите тому, что видите, попробуйте еще несколько раз. Почему это работает? Вот длинное объяснение , а вот короткое .

0 голосов
/ 11 марта 2010

Это не имеет ничего общего с производным классом, вызывающим исходную реализацию и затем также вызывающим Balance, не так ли?

Я думаю, вам, вероятно, нужно взглянуть на сгенерированный машинный код, чтобы увидеть, что отличается. Все, что я могу видеть из исходного кода, это то, что вы изменили много статических методов на виртуальные методы, вызываемые полиморфно ... в первом случае JIT точно знает, какой метод будет вызван, и может выполнить инструкцию прямого вызова, возможно, даже в соответствии. Но с полиморфным вызовом у него нет другого выбора, кроме как выполнить поиск в v-таблице и косвенный вызов. Этот поиск представляет значительную часть выполняемой работы.

Жизнь может стать немного лучше, если вы вызовете ((TreeType) this) .Method () вместо this.Method (), но, скорее всего, вы не сможете удалить полиморфный вызов, если вы также не объявите переопределяющие методы как закрытые. И даже в этом случае вы можете заплатить штраф за проверку времени выполнения в этом экземпляре.

Помещение вашего повторно используемого кода в базовые статические методы в базовом классе также может помочь, но я думаю, что вы все равно будете платить за полиморфные вызовы. Обобщения C # не оптимизируются так хорошо, как шаблоны C ++.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...