Почему в этом простом тесте скорость метода связана с порядком запуска?

Я делал другие эксперименты, пока это странное поведение не попалось мне на глаза.

код скомпилирован в версии x64.

если ключ в 1, , третий запуск метода списка стоит 40% больше времени, чем первые 2. вывод

List costs 9312
List costs 9289
Array costs 12730
List costs 11950

если ключ в 2, , третий запуск метода Array стоит на 30% больше времени, чем первые 2. вывод

Array costs 8082
Array costs 8086
List costs 11937
Array costs 12698

Вы можете видеть шаблон, полный код прилагается к следующему (просто компилировать и запускать): {представленный код минимален для запуска теста. Фактически код, используемый для получения надежного результата, более сложный, я завернул метод и протестировал его более 100 раз после надлежащего прогрева}

class ListArrayLoop
{
    readonly int[] myArray;
    readonly List<int> myList;
    readonly int totalSessions;

    public ListArrayLoop(int loopRange, int totalSessions)
    {
        myArray = new int[loopRange];
        for (int i = 0; i < myArray.Length; i++)
        {
            myArray[i] = i;
        }
        myList = myArray.ToList();
        this.totalSessions = totalSessions;
    }
    public  void ArraySum()
    {
        var pool = myArray;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }
    public void ListSum()
    {
        var pool = myList;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }

}
class Program
{
    static void Main(string[] args)
    {
        Stopwatch sw = new Stopwatch();
        ListArrayLoop test = new ListArrayLoop(10000, 100000);

        string input = Console.ReadLine();


        if (input == "1")
        {
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}",sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
        }
        else
        {
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
        }

        Console.ReadKey();
    }
}

Ответ 1

Короткий ответ. Это потому, что CRL имеет оптимизацию для методов отправки, называемых интерфейсом. Поскольку конкретный вызов метода интерфейса выполняется в том же типе (который реализует этот интерфейс), CLR использует быструю диспетчерскую процедуру (всего 3 команды), которая проверяет только фактический тип экземпляра, а в случае совпадения он перескакивает непосредственно на предварительно вычисленный адрес конкретного метод. Но когда один и тот же вызов метода интерфейса выполняется на примере другого типа, CLR-переключатели отправляют на более медленную процедуру (которая может отправлять методы для любого фактического типа экземпляра).

Длинный ответ: Во-первых, посмотрите, как объявлен метод System.Linq.Enumerable.Sum() (я пропустил проверку достоверности исходного параметра, поскольку он не важно это в этом случае):

public static int Sum(this IEnumerable<int> source)
{
    int num = 0;
    foreach (int num2 in source)
        num += num2;
    return num;
}

Итак, все типы, которые реализуют IEnumerable <int> может вызывать этот метод расширения, включая int [] и List <int> . Ключевое слово foreach - это просто аббревиатура для получения перечислителя через IEnumerable <T> .GetEnumerator() и итерации по всем значениям. Таким образом, этот метод действительно делает следующее:

    public static int Sum(this IEnumerable<int> source)
    {
        int num = 0;
        IEnumerator<int> Enumerator = source.GetEnumerator();
        while(Enumerator.MoveNext())
            num += Enumerator.Current;
        return num;
    }

Теперь вы можете четко видеть, что тело метода содержит три вызова метода для переменных типа интерфейса: GetEnumerator(), MoveNext() и Current (хотя Current на самом деле является свойством, а не методом, значение чтения из свойства просто вызывает соответствующий getter метод).

GetEnumerator() обычно создает новый экземпляр некоторого вспомогательного класса, который реализует IEnumerator <T> и, следовательно, способен возвращать все значения один за другим. Важно отметить, что в случае int [] и List <int> , типы перечислений, возвращаемые GetEnumerator(), эти два класса различны. Если источник аргумента имеет тип int [], то GetEnumerator() возвращает экземпляр типа SZGenericArrayEnumerator <int> и если источник имеет тип List <int> , то он возвращает экземпляр типа List <int> + Enumerator <int> .

Два других метода (MoveNext() и Current) неоднократно вызываются в узком цикле, и поэтому их скорость имеет решающее значение для общей производительности. Ненужный метод вызова для переменной типа интерфейса (такой как IEnumerator <int> ) не так прост, как вызов метода обычного экземпляра. CLR должен динамически обнаруживать фактический тип объекта в переменной, а затем выяснить, какой метод объекта реализует соответствующий метод интерфейса.

CLR пытается избежать этого многократного поиска на каждом вызове с небольшим трюком. Когда в первый раз вызывается особый метод (например, MoveNext()), CLR находит фактический тип экземпляра, на котором выполнен этот вызов (например, SZGenericArrayEnumerator <int> в случае, если вы вызвали Sum on int []), и находит адрес метод, который реализует соответствующий метод для этого типа (то есть адрес метода SZGenericArrayEnumerator <int> .MoveNext()). Затем он использует эту информацию для создания вспомогательного метода диспетчеризации, который просто проверяет, является ли фактический тип экземпляра таким же, как при первом вызове (это SZGenericArrayEnumerator <int> ), и если он есть, он непосредственно переходит к найденному ранее адресу метода, Таким образом, при последующих вызовах сложный поиск метода не выполняется до тех пор, пока тип экземпляра остается неизменным. Но когда вызов выполняется в перечислителе другого типа (например, List <int> + Enumerator <int> в случае вычисления суммы List <int> ), CLR больше не использует этот метод быстрой рассылки. Вместо этого используется другой (универсальный) и гораздо более медленный метод диспетчеризации.

До тех пор, пока Sum() вызывается только в массиве, CLR отправляет вызовы GetEnumerator(), MoveNext() и Current, используя быстрый метод. Когда Sum() также вызывается в списке, CLR переключается на более медленный способ диспетчеризации, и поэтому производительность снижается.

Если ваша работа связана с производительностью, используйте свой собственный метод расширения Sum() для каждого типа, на который вы хотите вызвать Sum(). Это гарантирует, что CLR будет использовать метод быстрой диспетчеризации. Например:

public static class FasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        foreach (int num2 in source)
            num += num2;
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        foreach(int num2 in source)
            num += num2;
        return num;
    }
}

Или даже лучше, избегайте использования IEnumerable <T> вообще (потому что он все еще приносит заметные накладные расходы). Например:

public static class EvenFasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        for(int i = 0; i < source.Length; i++)
            num += source[i];
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        for(int i = 0; i < source.Count; i++)
            num += source[i];
        return num;
    }
}

Вот результаты моего компьютера:

  • Ваша оригинальная программа: 9844, 9841, 12545, 14384
  • FasterSumExtensions: 6149, 6445, 754, 6145
  • EvenFasterSumExtensions: 1557, 1561, 553, 1574

Ответ 2

Надуманные проблемы дают вам надуманные ответы.

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

Большинство оптимизаций, которые люди пытаются сделать в вашей ситуации, тратят 6 часов на то, чтобы сократить время выполнения на 1 секунду. Большинство небольших программ не будут выполняться достаточно времени, чтобы компенсировать затраты, потраченные на "оптимизацию".


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


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

Ответ 3

Ваша проблема - ваш тест. Когда вы сравниваете код, вы должны следовать нескольким руководящим принципам:

  • Сходство с процессором: используйте только один процессор, обычно не # 1.
  • Warmup: всегда выполняйте тест несколько раз спереди.
  • Продолжительность: убедитесь, что продолжительность теста составляет не менее 500 мс.
  • Среднее: среднее количество одновременных попыток удаления аномалий.
  • Очистка: принудительно запустите GC для сбора выделенных объектов между тестами.
  • Кулдаун: разрешить процессу спать в течение короткого периода времени.

Поэтому, используя эти рекомендации и переписывая ваши тесты, я получаю следующие результаты:

Выполнить 1

Enter test number (1|2): 1
ListSum averages 776
ListSum averages 753
ArraySum averages 1102
ListSum averages 753
Press any key to continue . . .

Выполнить 2

Enter test number (1|2): 2
ArraySum averages 1155
ArraySum averages 1102
ListSum averages 753
ArraySum averages 1067
Press any key to continue . . .

Итак, здесь используется последний тестовый код:

static void Main(string[] args)
{
    //We just need a single-thread for this test.
    Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(2);
    System.Threading.Thread.BeginThreadAffinity();

    Console.Write("Enter test number (1|2): ");
    string input = Console.ReadLine();

    //perform the action just a few times to jit the code.
    ListArrayLoop warmup = new ListArrayLoop(10, 10);
    Console.WriteLine("Performing warmup...");
    Test(warmup.ListSum);
    Test(warmup.ArraySum);
    Console.WriteLine("Warmup complete...");
    Console.WriteLine();

    ListArrayLoop test = new ListArrayLoop(10000, 10000);

    if (input == "1")
    {
        Test(test.ListSum);
        Test(test.ListSum);
        Test(test.ArraySum);
        Test(test.ListSum);
    }
    else
    {
        Test(test.ArraySum);
        Test(test.ArraySum);
        Test(test.ListSum);
        Test(test.ArraySum);
    }
}

private static void Test(Action test)
{
    long totalElapsed = 0;
    for (int counter = 10; counter > 0; counter--)
    {
        try
        {
            var sw = Stopwatch.StartNew();
            test();
            totalElapsed += sw.ElapsedMilliseconds;
        }
        finally { }

        GC.Collect(0, GCCollectionMode.Forced);
        GC.WaitForPendingFinalizers();
        //cooldown
        for (int i = 0; i < 100; i++)
            System.Threading.Thread.Sleep(0);
    }
    Console.WriteLine("{0} averages {1}", test.Method.Name, totalElapsed / 10);
}

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

Ответ 4

Слишком много для комментария, поэтому CW - не стесняйтесь включать, и я удалю это. Данный код немного для меня, но проблема все еще интересна. Если вы смешиваете вызовы, вы получаете более низкую производительность. Этот код подчеркивает это:

static void Main(string[] args)
{
    var input = Console.ReadLine();

        var test = new ListArrayLoop(10000, 1000);

        switch (input)
        {
            case "1":
                Test(test.ListSum);
                break;
            case "2":
                Test(test.ArraySum);
                break;
            case "3":
                // adds about 40 ms
                test.ArraySum();
                Test(test.ListSum);
                break;
            default:
                // adds about 35 ms
                test.ListSum();
                Test(test.ArraySum);
                break;
        }

}

private static void Test(Action toTest)
{
    for (int i = 0; i < 100; i++)
    {
        var sw = Stopwatch.StartNew();
        toTest();
        sw.Stop();
        Console.WriteLine("costs {0}", sw.ElapsedMilliseconds);
        sw.Reset();
    }
}

Ответ 5

Списки реализованы в .NET с массивами, поэтому средняя производительность должна быть одинаковой (так как вы не изменяете ее длину).

Похоже, вы усреднили сумму sum() s 'раз достаточно, это может быть проблема GC с итератором, используемым в методе sum().

Ответ 6

Hm, это действительно выглядит странно... Мое предположение: вы вызываете .sum() в пуле переменных с типом var. Пока вы работаете только с одним типом (либо списком, либо массивом), вызов sum() однозначен и может быть оптимизирован. Использование нового класса var является неоднозначным и должно быть разрешено, поэтому дальнейшие вызовы вызовут удар производительности. У меня нет компилятора, поэтому попробуйте загрузить другой класс, который поддерживает sum() и сравнивает время. Если я прав, я бы ожидал снова удара производительности, но на этот раз не так много.

Ответ 7

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

Дальнейшие вызовы уже выигрывают от этого (если массив вписывается в кеш). Когда вы меняете метод, кеш является недействительным, и вам нужно получить все снова из памяти.

поэтому вызов: список, массив, список, массив, список, массив должен быть медленнее, чем: список, список, список, массив, массив, массив

Но это не детерминировано с точки зрения программиста, так как вы не знаете состояние кеша или других единиц, влияющих на решения кэширования.