Почему листинг List <T> в IList <T> приводит к снижению производительности?

Я делал некоторые показатели производительности, и я столкнулся с чем-то, что кажется мне довольно странным. Я выполняю следующие две функции:

  private static void DoOne()
      {
         List<int> A = new List<int>();
         for (int i = 0; i < 200; i++) A.Add(i);
          int s=0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < A.Count; c++) s += A[c];
         }

      }

   private static void DoTwo()
      {
         List<int> A = new List<int>();
         for (int i = 0; i < 200; i++) A.Add(i);
         IList<int> L = A;
         int s = 0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < L.Count; c++) s += L[c];
         }

      }

Даже при компиляции в режиме выпуска результаты таймингов последовательно показывали, что DoTwo занимает ~ 100 дольше, чем DoOne:

 DoOne took 0.06171706 seconds.
 DoTwo took 8.841709 seconds.

Учитывая тот факт, что Список непосредственно реализует IList, я был очень удивлен результатами. Может ли кто-нибудь прояснить это поведение?

Детали gory

Отвечая на вопросы, вот полный код и изображение настроек сборки проекта:

Dead Image Link

using System;
using System.Collections.Generic;
using System.Text;
using System.Diagnostics;
using System.Collections;

namespace TimingTests
{
   class Program
   {
      static void Main(string[] args)
      {
         Stopwatch SW = new Stopwatch();
         SW.Start();
         DoOne();
         SW.Stop();

         Console.WriteLine(" DoOne took {0} seconds.", ((float)SW.ElapsedTicks) / Stopwatch.Frequency);
         SW.Reset();
         SW.Start();
         DoTwo();
         SW.Stop();

         Console.WriteLine(" DoTwo took {0} seconds.", ((float)SW.ElapsedTicks) / Stopwatch.Frequency);

      }

      private static void DoOne()
      {
         List<int> A = new List<int>();
         for (int i = 0; i < 200; i++) A.Add(i);
         int s=0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < A.Count; c++) s += A[c];
         }

      }
      private static void DoTwo()
      {
         List<int> A = new List<int>();
         for (int i = 0; i < 200; i++) A.Add(i);
         IList<int> L = A;
         int s = 0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < L.Count; c++) s += L[c];
         }

      }
   }
}

Спасибо за все хорошие ответы (особенно @kentaromiura). Я бы закрыл вопрос, хотя я чувствую, что мы все еще пропускаем важную часть головоломки. Почему доступ к классу через интерфейс, который он реализует, будет намного медленнее? Единственное отличие, которое я вижу в том, что доступ к функции через интерфейс подразумевает использование виртуальных таблиц, в то время как обычно функции можно вызывать напрямую. Чтобы убедиться, что это так, я сделал пару изменений в вышеуказанном коде. Сначала я ввел два почти одинаковых класса:

  public class VC
  {
     virtual public int f() { return 2; }
     virtual public int Count { get { return 200; } }

  }

  public class C
  {
      public int f() { return 2; }
      public int Count { get { return 200; } }

  }

Как вы видите, VC использует виртуальные функции, а C - нет. Теперь DoOne и DoTwo:

    private static void DoOne()
      {  C a = new C();
         int s=0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < a.Count; c++) s += a.f();
         }

      }
      private static void DoTwo()
      {
           VC a = new VC();
         int s = 0;
         for (int j = 0; j < 100000; j++)
         {
            for (int c = 0; c < a.Count; c++) s +=  a.f();
         }

      }

И действительно:

DoOne took 0.01287789 seconds.
DoTwo took 8.982396 seconds.

Это еще более страшно - вызовы виртуальных функций в 800 раз медленнее? поэтому пара вопросов сообществу:

  • Можете ли вы воспроизвести? (Учитывая факт, что у всех была худшая производительность раньше, но не так плохо, как у меня)
  • Вы можете объяснить?
  • (это может быть самое главное) - можете ли вы подумать о способ избежать?

Боаз

Ответы

Ответ 1

Сначала я хочу поблагодарить всех за их ответы. Это было действительно важно на пути, определяющем наше происхождение. Особая благодарность @kentaromiura, которая нашла ключ, необходимый для того, чтобы разобраться в деталях.

Источник замедления использования List <T> через IList <T> интерфейсом является отсутствие возможности JIT-complier встроить функцию get свойства Item. Использование виртуальных таблиц, вызванных доступом к списку через интерфейс IList, предотвращает это.

В качестве доказательства я написал следующий код:

      public class VC
      {
         virtual public int f() { return 2; }
         virtual public int Count { get { return 200; } }

      }

      public class C
      {
         //[MethodImpl( MethodImplOptions.NoInlining)]
          public int f() { return 2; }
          public int Count 
          {
            // [MethodImpl(MethodImplOptions.NoInlining)] 
            get { return 200; } 
          }

      }

и изменили классы DoOne и DoTwo на следующее:

      private static void DoOne()
      {
         C c = new C();
         int s = 0;
         for (int j = 0; j < 100000; j++)
         {
            for (int i = 0; i < c.Count; i++) s += c.f();
         }

      }
      private static void DoTwo()
      {
         VC c = new VC();
         int s = 0;
         for (int j = 0; j < 100000; j++)
         {
            for (int i = 0; i < c.Count; i++) s += c.f();
         }

      }

Разумеется, время функции теперь очень похоже на предыдущее:

 DoOne took 0.01273598 seconds.
 DoTwo took 8.524558 seconds.

Теперь, если вы удаляете комментарии перед MethodImpl в классе C (заставляя JIT не встраивать) - время становится:

DoOne took 8.734635 seconds.
DoTwo took 8.887354 seconds.

Voila - методы принимают почти одно и то же время. Вы можете видеть, что метод DoOne все еще немного быстрый, что согласуется с дополнительными издержками виртуальной функции.

Ответ 2

Заметка для всех, кто пытается сравнить такие вещи.

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

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

Ответ 3

Профилирование один на один:

Тестирование с помощью компилятора Snippet.

используя результаты вашего кода:

0.043s против 0.116s

исключение временного L

0.043s против 0.116s - ininfluent

путем кэширования A.count в cmax для обоих методов

0.041s против 0.076s

     IList<int> A = new List<int>();
     for (int i = 0; i < 200; i++) A.Add(i);

     int s = 0;
     for (int j = 0; j < 100000; j++)
     {
        for (int c = 0,cmax=A.Count;c< cmax;  c++) s += A[c];
     }

Теперь я попытаюсь замедлить DoOne, сначала попробуйте, перейдя к IList, прежде чем добавить:

for (int i = 0; i < 200; i++) ((IList<int>)A).Add(i);

0,041 с 0,076 с - так что добавление не используется

поэтому остается только одно место, где может произойти замедление: s += A[c]; поэтому я пробую это:

s += ((IList<int>)A)[c];

0.075s 0.075s - TADaaan!

кажется, что доступ к Count или индексному элементу медленнее в сопряженной версии:

EDIT: Просто для удовольствия взгляните на это:

 for (int c = 0,cmax=A.Count;c< cmax;  c++) s += ((List<int>)A)[c];

0,041 с 0,050 с

так что это не проблема с литой, а отраженная!

Ответ 4

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

Только для записи, вот мои результаты:

DoOne() -> 295 ms
DoTwo() -> 291 ms

И код:

        Stopwatch sw = new Stopwatch();

        sw.Start();
        {
            DoOne();
        }
        sw.Stop();

        Console.WriteLine("DoOne() -> {0} ms", sw.ElapsedMilliseconds);

        sw.Reset();

        sw.Start();
        {
            DoTwo();
        }
        sw.Stop();

        Console.WriteLine("DoTwo() -> {0} ms", sw.ElapsedMilliseconds);

Ответ 5

Я вижу некоторое существенное наказание за версию интерфейса, но нигде рядом с штрафом, который вы видите.

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

Ответ 6

Мои тесты показывают, что версия интерфейса будет примерно на 3 раза медленнее при компиляции в режиме деблокирования. Когда они скомпилированы в режиме отладки, они почти шеи и шеи.

--------------------------------------------------------
 DoOne Release (ms) |  92 |  91 |  91 |  92 |  92 |  92
 DoTwo Release (ms) | 313 | 313 | 316 | 352 | 320 | 318
--------------------------------------------------------
 DoOne Debug (ms)   | 535 | 534 | 548 | 536 | 534 | 537
 DoTwo Debug (ms)   | 566 | 570 | 569 | 565 | 568 | 571
--------------------------------------------------------

ИЗМЕНИТЬ

В моих тестах я использовал слегка измененную версию метода DoTwo, чтобы он был непосредственно сопоставим с DoOne. (Это изменение не показало заметной разницы в производительности.)

private static void DoTwo()
{
    IList<int> A = new List<int>();
    for (int i = 0; i < 200; i++) A.Add(i);
    int s = 0;
    for (int j = 0; j < 100000; j++)
    {
       for (int c = 0; c < A.Count; c++) s += A[c];
    }
}

Единственное различие между IL, сгенерированным для DoOne и (измененным) DoTwo, состоит в том, что команды callvirt для Add, get_Item и get_Count используют IList и ICollection, а не List.

Я предполагаю, что среда выполнения должна сделать больше работы, чтобы найти реальную реализацию метода, когда callvirt через интерфейс (и что компилятор/оптимизатор JIT может лучше работать с вызовами без интерфейса, чем вызов интерфейса при компиляции в режиме выпуска).

Кто-нибудь может это подтвердить?

Ответ 7

Я запускал это с помощью Jon Skeet Benchmark Helper, и я не вижу результатов, которые вы есть, время выполнения примерно одинаково между двумя методами.