Почему моя производительность замедляется к проверке, я перемещаю методы в базовый класс?

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

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

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

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 f)
        {
            var seeds = new int[] { 314159265, 271828183, 231406926, 141421356, 161803399, 266514414, 15485867, 122949829, 198491329, 42 };

            var times = new List();

            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.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.Create((x, y) => x.CompareTo(y));
            for (int i = 0; i < VALUE_COUNT; i++)
            {
                avlTree2 = avlTree2.Insert(rnd.NextDouble());
            }

            return avlTree2.Count;
        }
    }
}
  • AvlTree: вставляет 5 000 объектов в 121 мс
  • DerivedAvlTree: вставляет 5 000 объектов в 2 182 мс

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

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

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

18
задан Juliet 17 March 2010 в 23:33
поделиться

3 ответа

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

Кажется, нет разницы между прямыми и виртуальными вызовами, которые вызывают замедление. Это как-то связано с этими делегатами; Я не могу подробно объяснить, что это такое, но взгляд на сгенерированный 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 и посмотреть, как это повлияет на время выполнения - это поможет доказать или опровергнуть эту теорию.

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


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

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

Теперь время работы упадет до 38 мс здесь, что чуть выше исходной версии. Это шокирует меня, потому что на самом деле ничего не ссылается на этот метод! Частный метод Insert по-прежнему идентичен самому первому блоку кода в моем ответе. Я собирался изменить первый аргумент для ссылки на метод 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;
    }
}

Да, да, я знаю, это очень неработоспособно с использованием этого изменяемого внутреннего дерева состояния, но помните, что это не дерево сам по себе, это просто одноразовый "работоспособный" экземпляр. Никто никогда не говорил, что perf-opt хорош! Это единственный способ избежать создания нового 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 , обычно на несколько миллисекунд меньше в сборке релиза.

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

17
ответ дан 30 November 2019 в 09:11
поделиться

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

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

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

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

0
ответ дан 30 November 2019 в 09:11
поделиться

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

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

0
ответ дан 30 November 2019 в 09:11
поделиться
Другие вопросы по тегам:

Похожие вопросы: