Сравнительное тестирование пяти способов сравнить байтовые массивы в C#

Автор: Харченко Андрей Владимирович
Опубликовано: 18.11.2015
Версия текста: 1.0
Предпосылки
Что и чем я тестировал
Главное
Размеры данных
Протестированные методы
Методика тестирования
Результаты измерений и первые выводы
Диазассемблирование и анализ отдельных функций
Выводы
Список литературы

Предпосылки

Написав очередную утилиту и добившись её работоспособности, я запустил профайлер и увидел, что огромный процент времени занимает сравнение массивов байтов.

Функция CompareByteArrays() лежала на критическом пути, и с этим надо было что-то делать. Как решить проблему алгоритмически, я не видел: алгоритмы подбирались заранее, и никаких идей о том, как уменьшить итоговую сложность, у меня не возникло. Поскольку CLR не предоставляет стандартного способа сравнить два куска памяти, метод был написан на скорую руку самостоятельно: я с самого начала не предполагал, что он будет работать быстро, а значит, можно было попытаться его «ускорить».

Так что, засучив рукава, я набрал в поисковике «Best Way to Compare Byte Arrays in .Net» и прошёлся по результатам. Поразительно, но в абсолютном большинстве случаев люди предлагали использовать либо LINQ, либо Enumerable.SequenceEqual(), что практически одно и то же. Даже на StackOverflow это был самый популярный ответ. Т.е. катастрофически популярно заблуждение вида:

Compiler\run-time environment will optimize your loop so you don't need to worry about performance.

Что и чем я тестировал

Главное

Код компилировался и запускался на машине с Windows 7 x64 со всеми последними обновлениями для .Net 4.0 Client Profile, т.е. с настройками Microsoft Visual Studion 2010 по умолчанию, и только для конфигурации x86, т.е. это 32-битный код. Во-первых, потому что большая часть кода, с которой я сталкиваюсь, написана для 32-битных систем. Во-вторых, я считаю, что для .Net крайне важна производительность именно в этой конфигурации. В частности, потому, что огромный объём уже существующих компонентов написан для 32-бит, и переписывать их никто не будет, а с ними нужно взаимодействовать, т.е. они определят конфигурацию конечного приложения. Во-вторых, крайне малому числу приложений действительно нужно 64 бита – необходимая разрядность определяется в первую очередь требованиями к памяти и целевой платформой. Современные 64-разрядные x86-совместимые процессоры аппаратно поддерживают 32-битные задачи [1][2]. Логично, что Windows x64 эту возможность использует, исполняя 32-битный код напрямую [3]. Компиляция изначально 32-битного приложения в 64 бита умножает необходимую для него память и снижает его производительность, потому что опции компилятора размер TLB процессора не меняют, кэш первого уровня — тоже, а размер указателя удваивают, что в свою очередь ещё и необходимую границу выравнивания данных изменяет [4].

Размеры данных

В моей изначальной задаче требовалось сравнивать байтовые массивы размером 16 байт и размерами от 1 до 4*1024 байт. 16 байт — размер MD5, 4 Kb — размер стандартной страницы памяти в Windows, поэтому он был выбран для буфера ввода-вывода.

Однако тестировал сравнение я на блоках от 1 байта до 1/2 мегабайта, по той простой причине, что хэши бывают размером не только 16 байт, но и 4, и даже 2 байта (CRC16, CRС32), а страницы памяти не только по 4 килобайта, но и 2 МБ[2] и даже 4 МБ. Результаты, показанные на блоках 1/2 MB, оказались достаточны, чтобы делать вывод о работе с блоками больших размеров, к тому же моё время не безгранично (в процессе тестирования компьютер лучше не трогать, чтобы не отнимать время у описываемого процесса).

Исходя из результатов первых прогонов, я счёл достаточным сравнение методов на 25 различных размерах тестовых данных. Для этого я построил цикл тестирования таким образом, чтобы первый тестовый набор состоял из массивов размером 1 байт, а на каждом следующем шаге размер тестовых массивов умножался на одинаковый множитель.

Параметры задавались константами в коде
        private const int CountArrays = 128; //Количество сравниваемых тестовых наборовprivate const int StartArraySize = 1; //Стартовый размер тестового набораprivate const int MaxArraySize = 512 * 1024; //Максимальный размер тестового набораprivate const int StepsCount = 25; //Количество шаговprivate const int MeasurementsCount = 10; //Количество измерений
Цикл тестирования
        double sizeMultiplier = 1;
   DoInvestigationStep(sizeMultiplier);

   const int MaxMultiplier = MaxArraySize / StartArraySize;
   var stepMmltiplier = Math.Pow( MaxMultiplier, 1 / (double)StepsCount );
   for (var step = 1; step <= StepsCount; step++)
   {
      sizeMultiplier = Math.Pow(stepMmltiplier, (double) step);
      DoInvestigationStep(sizeMultiplier);
   }
Задание размера массива
        var arraySize = (int) (StartArraySize * p_SizeMultiplier);

Протестированные методы

Использование интерфейса System.Collections.IStructuralEquatable

Это относительно новый для C# способ, он появился только в .NET 4, поэтому он представлял для меня особый интерес.

          private static bool ByteArrayCompareWithIStructuralEquatable(byte[] p_BytesLeft, byte[] p_BytesRight)
{
   IStructuralEquatable eqa1 = p_BytesLeft;
   return eqa1.Equals(p_BytesRight, StructuralComparisons.StructuralEqualityComparer);
}

LINQ, а точнее Enumerable.SequenceEqual

Это один из самых простых методов, самый простой для реализации. Кроме того, это самый популярный метод.

          private static bool ByteArrayCompareWithSequenceEqual(byte[] p_BytesLeft, byte[] p_BytesRight)
{
   return p_BytesLeft.SequenceEqual(p_BytesRight);
}

PInvoke, для CRT функции memcmp()

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

PInvoke с «обвязкой»
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] p_BytesLeft, byte[] p_BytesRight, long p_Count);

private static bool ByteArrayCompareWithPInvoke(byte[] p_BytesLeft, byte[] p_BytesRight)
{
   // Validate buffers are the same length.// This also ensures that the count does not exceed the length of either buffer.  return p_BytesLeft.Length == p_BytesRight.Length
              && memcmp(p_BytesLeft, p_BytesRight, p_BytesLeft.Length) == 0;
}

В лоб. Т.е. прямое сравнение байтов в цикле for(;;)

Это самый очевидный способ сравнить два массива, именно поэтому он представляет интерес.

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

Функция ByteArrayCompareWithSimplest()
          private static bool ByteArrayCompareWithSimplest(byte[] p_BytesLeft, byte[] p_BytesRight)
{
   if (p_BytesLeft.Length != p_BytesRight.Length)
      return false;

   var length = p_BytesLeft.Length;

   for (int i = 0; i < length; i++)
   {
      if (p_BytesLeft[i] != p_BytesRight[i])
         return false;
   }

   return true;
}

Оптимизированный unsafe-метод от Хафора Стефансона

Этот оптимизированный метод делает сравнение блоками размером 64 бита для возможно большей части массива, рассчитывая на то, что начало массива выравнено по границе QWORD. Он также будет работать, если нет QWORD-выравнивания, но с меньшей скоростью (http://stackoverflow.com/questions/43289/comparing-two-byte-arrays-in-net). Для буферов среднего размера этот метод оказался самым быстрым.

Функция UnsafeCompare()
          // Copyright (c) 2008-2013 Hafthor Stefansson
          // Distributed under the MIT/X11 software license
          // Ref: http://www.opensource.org/licenses/mit-license.php.
          private static unsafe
          bool UnsafeCompare(byte[] a1, byte[] a2)
{
   if (a1 == null || a2 == null || a1.Length != a2.Length)
      return false;
   fixed (byte* p1 = a1, p2 = a2)
   {
      byte* x1 = p1, x2 = p2;
      int l = a1.Length;
      for (int i = 0; i < l / 8; i++, x1 += 8, x2 += 8)
         if (*((long*) x1) != *((long*) x2))
            return false;
      if ((l & 4) != 0)
      {
         if (*((int*) x1) != *((int*) x2)) return false;
         x1 += 4;
         x2 += 4;
      }
      if ((l & 2) != 0)
      {
         if (*((short*) x1) != *((short*) x2)) return false;
         x1 += 2;
         x2 += 2;
      }
      if ((l & 1) != 0)
         if (*((byte*) x1) != *((byte*) x2))
            return false;
      return true;
   }
}

Методика тестирования

Тестовый набор

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

В качестве тестового набора был выбран «зубчатый» массив (в оригинальной документации — «Jagged Array»). Было несколько альтернатив, например, List<byte[]> и LinkedList<byte[]>, но альтернативы не устроили временем доступа к элементу, хотя и массив в .NET здесь не блещет: CLR в любом случае проверяет индекс на выход за границы массива, даже если это массив с нулевой базой.

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

Генерация тестовых данных
          private
          static
          void PrepareTestData(int p_ArraySize)
{
   for (int i = 0; i < CountArrays; i++)
   {
      var byteArray = newbyte[p_ArraySize];
      byteArray[p_ArraySize / 2] = (byte) (i & 0x000000ff);

      s_arrays[i] = byteArray;
   }
}

Выполнение сравнений

Так как все массивы предполагалось сравнивать со всеми, то общий вид метода, время исполнения которого измерялось, представляет собой два вложенных цикла по всем массивам набора, а внутри них вызывается испытуемый метод. Суть происходящего можно выразить кодом:

          for (int i = 0; i < CountArrays; i++)
      for (int j = 0; j < CountArrays; j++)
         Compare( s_arrays[i], s_arrays[j] );

Однако тут есть одно «но»: если никак не использовать результат «вычислений», то CLR имеет полное право само «вычисление» не производить. Я с этим сталкивался раньше, когда экспериментировал с C++. При включенной оптимизации «-O3» было весьма проблематично что-либо измерить, т.к. время раз за разом получалось нулевым. Поэтому такой сценарий было решено исключить с самого начала, так что результат работы функции сравнения «аккумулировался» и возвращался, а на самом верхнем уровне анализировался.

Сравнение без игнорирования результата
          var result = true;
   for (int i = 0; i < CountArrays; i++)
      for (int j = 0; j < CountArrays; j++)
      {
         var tmp = Compare( s_arrays[i], s_arrays[j] );

         result = result && tmp;
      }
   return result;
Анализ результата сравнения
          var stubLocalVar = p_СomparisonMethod();
   if (stubLocalVar)
      thrownew InvalidOperationException();

Поскольку методов сравнений 5, а вышеописанный шаблон общий, то кажется логичным определить общий, «шаблонный» метод, и передать метод сравнения в него. Таким образом:

Неправильный метод вызова функций
          private
          static
          bool CompareArraysWithExternalMethod(Func<byte[], byte[], bool> p_Method)
{
   var result = true;
   for (int i = 0; i < CountArrays; i++)
      for (int j = 0; j < CountArrays; j++)
      {
         var tmp = p_Method( s_arrays[i], s_arrays[j] );

         result = result && tmp;
      }
   return result;
}

Такой способ, безусловно, исключает копирование/вставку и уменьшает вероятность ошибки, но в то же время создаёт излишнюю косвенность вызова и исключает встраивание метода (inlining), что, в свою очередь, приводит к потере точности измерений, особенно учитывая, что вызовов много. Именно поэтому мне пришлось создать пять практически одинаковых методов:

          private
          static
          bool CompareArraysWithUnsafeMethod();
privatestaticbool CompareArraysWithSimplestMethod();
privatestaticbool CompareArraysWithSequenceEqualMethod();
privatestaticbool CompareArraysWithPInvokeMethod();
privatestaticbool CompareArraysWithIStructuralEquatableMethod();

Однако уровнем выше я уже мог применить шаблонный метод.

Из приведённого ниже кода видно, что функция MeasureComparisonTime(), в которой измеряется время выполнения метода сравнения, принимает в качестве параметра делегат типа Func<bool>. Именно его время выполнения и измеряется.

Функции, производящие измерение метода
          private
          static
          long GetMinimalMesuredValue(int p_MeasurementNumber, long p_PreviousValue,
                                           long p_MeasuredValue)
{
   var result = p_MeasurementNumber == 0
                      ? p_MeasuredValue
                      : Math.Min(p_PreviousValue, p_MeasuredValue);
   return result;
}

privatestaticlong MeasureComparisonTime(Func<bool> p_Method, long p_PreviousTime,
                                          int p_MeasurementNumber)
{
   GC.Collect();
   GC.Collect();

   s_stopwatch.Start();
   var stubLocalVar = p_Method();
   s_stopwatch.Stop();

   if (stubLocalVar)
      thrownew InvalidOperationException();

   var result = GetMinimalMesuredValue(p_MeasurementNumber, p_PreviousTime, s_stopwatch.ElapsedTicks);
   s_stopwatch.Reset();

   return result;
}

Методика измерений

Измерение времени производилось с помощью штатного секундомера (класс System.Diagnostics.Stopwatch), в основе которого лежит QueryPerformanceCounter() (http://msdn.microsoft.com/ru-ru/library/system.diagnostics.stopwatch(v=vs.110).aspx ). Естественно, интересовали не миллисекунды, а тики: в случае крохотных массивов все измерения в миллисекундах были бы нулевыми. Также была идея задействовать свой "велосипед" на основе RDTSC[6], но, во-первых, несколько первых прогонов показали, что для текущей методики точности вполне хватает, а во-вторых, предупреждение о необходимости использования ProcessThread.ProcessorAffinity, которое появилось в последних версиях документации, позволяет предположить, что в основе работы этого класса лежит вышеупомянутый счётчик тактов процессора. Все измерения повторялись 10 раз, и бралось лучшее время. Число 10 было подобрано экспериментально, как дающее достаточно точные результаты при минимальных затратах времени.

Функция, производящая все измерения
          private
          static MeasurementsResults DoMeasurements()
{
   MeasurementsResults result;
   result.SimplestTime = 0;
   result.SequenceEqualTime = 0;
   result.PInvokeTime = 0;
   result.IStructuralEquatableTime = 0;
   result.UnsafeTime = 0;

   for (int measurementNumber = 0; measurementNumber < MeasurementsCount; measurementNumber++)
   {
      Console.WriteLine("Стартует измерение номер {0}", measurementNumber);

      result.SimplestTime = MeasureComparisonTime(CompareArraysWithSimplestMethod,
                                                  result.SimplestTime,
                                                  measurementNumber);

      result.SequenceEqualTime = MeasureComparisonTime(CompareArraysWithSequenceEqualMethod,
                                                       result.SequenceEqualTime,
                                                       measurementNumber);

      result.PInvokeTime = MeasureComparisonTime(CompareArraysWithPInvokeMethod,
                                                 result.PInvokeTime,
                                                 measurementNumber);

      result.IStructuralEquatableTime = MeasureComparisonTime(CompareArraysWithIStructuralEquatableMethod,
                                                              result.IStructuralEquatableTime,
                                                              measurementNumber);

      result.UnsafeTime = MeasureComparisonTime(CompareArraysWithUnsafeMethod,
                                                result.UnsafeTime,
                                                measurementNumber);
   }

   return result;
}

Результаты измерений и первые выводы

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

Лучшее время (тики)

Размер массива (байты)

unsafe

В лоб

PInvoke

SequenceEqual

IStructuralEquatable

276

1

1,7

1

6

10,5

55

276

1

1,7

1

6,3

10,1

55,7

325

2

1,3

1

5,3

11

95,2

374

4

1,1

1

4,8

11,4

121,3

413

8

1

1,3

5

14,1

181,2

412

13

1

1,7

4,7

17,5

252,5

490

23

1

2,3

3,7

22,1

364,8

554

39

1

3,4

3,5

30,1

535,9

691

67

1

4,3

2,9

39,1

727,5

887

114

1

5,6

2,4

50,3

964,1

1212

194

1

4,6

2,1

61,5

1190,9

1875

328

1

4,8

1,8

65,8

1295,8

2897

556

1

5

1,6

71,5

1416,9

4565

942

1

5,3

1,4

76,3

1521,1

7711

1595

1

5,2

1,3

76,2

1521,2

12482

2702

1

5,4

1,3

79,4

1593,6

20692

4576

1

5,5

1,2

81,2

1626,2

34369

7750

1

5,6

1,2

82,8

1657,1

58048

13124

1

5,6

1,2

82,9

1661,9

97511

22226

1

5,6

1,2

83,6

1677,3

173805

37640

1

5,4

1,1

79,5

1585,7

295989

63743

1

5,3

1,1

79,3

1574,9

588797

107949

1

4,6

1,1

67,5

1340,9

1010768

182811

1

4,6

1,1

67

1340,4

1705668

309590

1

4,6

1,1

67,3

1334,1

2885452

524287

1

4,6

1,1

67,3

1329,1

Результаты удивили следующим:

Первое: CRT функция просто обязана быть хорошо оптимизирована, и я рассчитывал, что на определённом этапе (размере тестового массива) её скорость перекроет накладные расходы (overhead) платформенного вызова, но этого не произошло. Позже я повторил тестирования с массивами значительно большего размера, но даже на 1.5 мегабайтах unsafe-код оказывался быстрее.

Второе: я догадывался, что IStructuralEquatable окажется медленным, но цифры меня просто поразили: такого я точно не ожидал.

Диазассемблирование и анализ отдельных функций

Столь серьёзная разница между unsafe и Simplest на больших массивах (я ожидал не более двух-трёхкратного «перевеса») позволяет предположить, что с оптимизацией циклов и обращений к элементам массива в .Net не всё так гладко, как утверждают приверженцы .Net. Соответственно, я не отказал себе в удовольствии посмотреть на результаты работы JIT'ера, т.е. на непосредственно генерируемый машинный код.

Анализ функции CompareArraysWithSimplestMethod

Для этого в начало метода был вставлен Thread.Sleep(60 * 1000); после запуска релизной сборки я подцепился к процессу OllyDebug'ом. Такой хитрый ход нужен был для того, чтобы ни в коем случае не порушить оптимизации CLR — увидеть картину такой, какая есть.


Спустившись вниз по стеку вызовов от ntdll.NtDelayExecution() и SleepEx(), я нашёл свою функцию и, после её внимательного изучения, был в очередной раз неприятно удивлён. Что мне здесь очень не понравилось:

Анализ функции CompareArraysWithSimplestMethod

Результат декомпиляции этой функции разжёг моё любопытство и навёл на мысль об остальных функциях. Я решил сравнить CRT-функцию и unsafe-вариант. Сперва я решил посмотреть на unsafe-вариант. Для этого я проделал те же операции, что и для первой функции, за исключением того, что Thread.Sleep() вставил не в саму функцию, а перед её вызовом. Это вряд ли могло как-либо повлиять на суть происходящего, но несколько упростило декомпиляцию: unsafe-функция явно сложнее первой.

Место вставки Thread.Sleep()
          private
          static
          bool CompareArraysWithUnsafeMethod()
{
   var result = true;
   for (int i = 0; i < CountArrays; i++)
      for (int j = 0; j < CountArrays; j++)
      {
         Thread.Sleep( 60 * 1000 );var tmp = UnsafeCompare(s_arrays[i], s_arrays[j]);

Интерес также представляет первая половина этой функции, т.е. до момента вызова UnsafeCompare(). Она, так же, как и первая, демонстрирует, что любое обращение к элементу массива вызывает проверку индекса на вхождение в границы массива. Я подчеркнул это в листинге.

CPU Disasm CompareArraysWithUnsafeMethod
          Address   Hex dump          Command                                 Comments
001B0B88    55              pushebp ; Стандартный пролог
001B0B89    8BEC            movebp, esp
001B0B8B    57              pushedi
001B0B8C    56              pushesi
001B0B8D    53              pushebx
001B0B8E    BF 01000000     movedi, 1                          ; result = true;
001B0B93    33DB            xorebx, ebx ; for (int i = 0;
001B0B95    33F6            xoresi, esi ; for (int j = 0;
001B0B97    B9 60EA0000     movecx, 0EA60                      ; Thread.Sleep( 60 * 1000 );
001B0B9C    E8 0CFBC161     call    clr.ThreadNative::Sleep        
001B0BA1    8B15 A8337A03   movedx, [s_arrays]                 ; EDX  <-- s_arrays
001B0BA7    3B5A 04         cmpebx, [edx+4]                    ; Compare(s_arrays.Length, первый индекс)
001B0BAA    73 31           jaeshort stub_CLR.JIT_RngChkFail  
001B0BAC    8B4C9A 0C       movecx, [ebx*4+edx+0C]             ; ECX <-- s_arrays[i]
001B0BB0    3B72 04         cmpesi, [edx+4]                    ; Compare(s_arrays.Length, второй индекс)
001B0BB3    73 28           jaeshort stub_CLR.JIT_RngChkFail  
001B0BB5    8B54B2 0C       movedx, [esi*4+edx+0C]             ; EDX <-- s_arrays[j]
001B0BB9    FF15 F0381400   callnear UnsafeCompare

Результат декомпиляции функции оказался довольно большим и на один экран не помещался, поэтому я не стал приводить скриншот окна дебагера. Так как большой цельный листинг читать утомительно и непродуктивно, то здесь я привожу его логически объединёнными кусками, сопровождая каждый кусок кратким комментарием.

UnsafeCompare().ParametersChecking
          ;a1 ========> ECX
          ;a2 ========> EDX
          Address   Hex dump          Command                Comments
001B0BF8    55              pushebp; Стандартный пролог
001B0BF9    8BEC            movebp, esp
 
001B0BFB    57              pushedi; сохранение регистров в стеке нужно, вероятно, затем, чтобы
001B0BFC    56              pushesi; использовать их в дальнейшем
001B0BFD    53              pushebx;
                                             
001B0BFE    83EC 0C         subesp, 0C        ; вершина стека выше сохранённых регистров на 3*sizeof(DWORD), т.е. будет 3 переменных001B0C01    33C0            xor     eax, eax       001B0C03    8945 F0         mov     [ebp-10], eax  ; var1 <-- 0001B0C06    8945 EC         mov     [ebp-14], eax  ; var2 <-- 0
                                             
001B0C09    85C9            testecx, ecx; Compare(a1, null)
001B0C0B    74 0C           jeshort return1 
                                             
001B0C0D    85D2            testedx, edx; Compare(a2, null)
001B0C0F    74 08           jeshort return1 
                                             
001B0C11    8B41 04         moveax, [ecx+4]   ; eax <-- a1.Length
001B0C14    3B42 04         cmpeax, [edx+4]   ; Compare(eax, a2.Length)
001B0C17    74 0A           jeshort argsIsValid 

return1:
001B0C19    33C0            xoreax, eax; result <-- 0
001B0C1B    8D65 F4         leaesp, [ebp-0C]  
001B0C1E    5B              popebx            
001B0C1F    5E              popesi            
001B0C20    5F              popedi            
001B0C21    5D              popebp            
001B0C22    C3              ret

argsIsValid:

В соответствии с соглашением вызова fastcall, которому следует .NET Framework [7][8], в регистрах ecx и edx находятся первый и второй параметры, в соответствии с порядком слева направо. В приведённом листинге хорошо видна последовательная проверка входных параметров, он почти однозначно соответствует коду на C#

          if (a1 == null || a2 == null || a1.Length != a2.Length)
      returnfalse;

Однако особого внимания заслуживают выделенные строки: назначение их неясно, и они запутывают. Чтобы понять, что это такое и зачем оно может быть нужно, мне пришлось поставить ещё один эксперимент, в ходе которого я написал простейшую unsafe-функцию на C#, которая использует предложение fixed и обращение по указателю, и провёл с ней аналогичные действия.

Unsafe функция
          private
          static
          unsafe
          int func1(byte[] param1)
{
   fixed (byte* p = param1)
   {
      return *p;
   }
}
CPU Disasm
          ;param1 ========> ECX
          Address   Hex dump          Command                                  Comments
$ ==>       55              pushebp ; Стандартный пролог
$+1         8BEC            movebp, esp$+3         50              push    eax$+4         33C0            xor     eax, eax$+6         8945 FC         mov     [ebp-4], eax                     ; var1 <-- 0

$+9         85C9            testecx, ecx ; Compare(param1, null)
$+B         74 06           jeshort param_is_null

$+D         8379 04 00      cmpdwordptr [ecx+4], 0             ; Compare(param1.Length, 0)
$+11        75 07           jneshort not_zero_len

param_is_null:
$+13        33D2            xoredx, edx
$+15        8955 FC         mov     [ebp-4], edx; var1 <-- 0
$+18        EB 0C           jmpshort ret_1

not_zero_len:
$+1A        8379 04 00      cmpdwordptr [ecx+4], 0
$+1E        76 10           jbeshortcall.JIT_RngChkFail

$+20        8D49 08         leaecx, [ecx+8]                    ; ecx <-- param1.BufferPointer
$+23        894D FC         mov     [ebp-4], ecx; var1 <-- ecx

ret_1:
$+26        8B45 FC         moveax, [ebp-4]                    ; eax <-- var1
$+29        0FB600          movzxeax, byteptr [eax]             ; eax <-- *eax

$+2C        8BE5            movesp, ebp; Стандартный эпилог
$+2E        5D              popebp

$+2F        C3              retcall.JIT_RngChkFail:
$+30        E8 B3BDC861     call    clr.JIT_RngChkFail
$+35        CC              int3

Из приведённого листинга становится понятным, что в результате JIT-компиляции переменная в предложении fixed обязательно помещается в стек, вне зависимости от доступности регистров общего назначения. Это хорошо видно из того факта, что регистр eax был сохранён в стеке, в дальнейшем не использовался и, следовательно, был свободным и доступным для операций (до смещения 0x26 от начала функции), но под хранение временных данных была использована стековая переменная по смещению [ebp-4] (назовём её var1). Переменная сразу же обязательно инициализируется нулём, несмотря на то, что потом это значение не используется, а просто затирается. Например, по смещению 0x15 в var1 снова заносится ноль, несмотря на то, что там к этому моменту уже хранится ноль.

Таким образом, становится понятным, что никакого особого смысла выделенные строки в листинге UnsafeCompare.CPU Disasm.ParametersChecking не несут, это всего лишь побочный эффект компиляции. Также из вышеприведённого листинга становится очевидным, что проверка массива в выражении fixed производится в три этапа: сначала массив проверяется на равенство null, потом его длина проверяется на равенство нулю (команда jne анализирует только ZF), и только затем на равенство нулю и отрицательность значения (jbe проверяет и ZF и CF). С моей точки зрения весьма странно, что последние два действия не были объединены в одно, и тем более странно, что команда cmp выполняется дважды, ведь условный переход не сбрасывает флаги.

Проведённый эксперимент значительно упрощает дальнейший анализ кода.

Предложение fixed из функции CompareArraysWithUnsafeMethod()
argsIsValid:

001B0C23    8379 04 00      cmpdwordptr [ecx+4], 0       ; Compare(a1.Length, 0) это первая проверка длины первого массива
001B0C27    75 07           jneshort a1Len_NonZero        ; только неравенство

001B0C29    33C0            xoreax, eax
001B0C2B    8945 F0         mov     [ebp-10], eax ; var1 <-- 0
001B0C2E    EB 10           jmpshort a1Len_Zero


a1Len_NonZero:
001B0C30    8379 04 00      cmpdwordptr [ecx+4], 0       ; Compare(a1.Length, 0) вторая (последняя) проверка длины первого массива
001B0C34    0F86 B5000000   jbecall.JIT_RngChkFail        ; если равно или меньше

001B0C3A    8D41 08         leaeax, [ecx+8]               ; eax <-- a1.BufferPointer
001B0C3D    8945 F0         mov     [ebp-10], eax ; var1 <-- eax


a1Len_Zero:
001B0C40    837A 04 00      cmpdwordptr [edx+4], 0       ; Compare(a2.Length, 0) это первая проверка длины второго массива
001B0C44    75 07           jneshort a2Len_NonZero        ; только неравенство

001B0C46    33D2            xoredx, edx
001B0C48    8955 EC         mov     [ebp-14], edx ; var2 <-- 0
001B0C4B    EB 10           jmpshort part3


a2Len_NonZero:
001B0C4D    837A 04 00      cmpdwordptr [edx+4], 0       ; Compare(a2.Length, 0) вторая (последняя) проверка длины второго массива
001B0C51    0F86 98000000   jbecall.JIT_RngChkFail        ; если равно или меньше

001B0C57    8D52 08         leaedx, [edx+8]               ; edx <-- a2.BufferPointer
001B0C5A    8955 EC         mov     [ebp-14], edx ; var2 <-- edx

Никаких сюрпризов компилятор здесь не преподнёс, т.е. алгоритм компиляции даёт легко предсказуемые результаты. Например, переменные, инициализируемые в предложении fixed, в любом случае размещаются в стеке.

Инициализация переменных внутри блока fixed
part3:
; ECX              <======= a1; EDX              <======= a2.BufferPointer; var1             <======= a1.BufferPointer; var2             <======= a2.BufferPointer
001B0C5D    8B45 F0         moveax, [ebp-10]              ; eax <-- var1
001B0C60    8BF0            movesi, eax ; esi <-- eax

001B0C62    8B45 EC         moveax, [ebp-14]              ; eax <-- var2
001B0C65    8BF8            movedi, eax ; edi <-- eax

001B0C67    8B41 04         moveax, [ecx+4]               ; eax <-- a1.Length
001B0C6A    8945 E8         mov     [ebp-18], eax ; var3 <-- eax

Инициализация переменных внутри блока fixed любопытна тем, что хорошо демонстрирует принцип, по которому JIT-компилятор генерирует код. Здесь хорошо видно, что вместо прямой пересылки значений из стековых переменных в индексные регистры, значения сначала помещаются в регистр-аккумулятор. Может быть, в этом и есть какой-нибудь тайный магический смысл, но выглядит это (пересылка в eax) просто как лишнее действие.

Цикл по 8 байт
001B0C6D    33C9            xorecx, ecx; счётчик цикла <-- 0

001B0C6F    8BD8            movebx, eax; ebx <-- a1.Length
001B0C71    85DB            testebx, ebx
001B0C73    79 03           jnsshort ebx_greaterZero
001B0C75    83C3 07         addebx, 7                     ; если отрицательно, прибавляем 7
ebx_greaterZero:
001B0C78    C1FB 03         sarebx, 3                     ; ebx <-- a1.Length / 8

001B0C7B    85DB            testebx, ebx
001B0C7D    7E 1D           jleshort fourBytesComparing


for8_body:
001B0C7F    8B06            moveax, [esi]
001B0C81    8B56 04         movedx, [esi+4]

001B0C84    3B57 04         cmpedx, [edi+4]
001B0C87    75 04           jneshort setResult_jumpReturn

001B0C89    3B07            cmpeax, [edi]
001B0C8B    74 04           jeshort for8_increment
setResult_jumpReturn:
001B0C8D    33C0            xoreax, eax; result <-- 0
001B0C8F    EB 56           jmpshort return2              ; goto return


for8_increment:
001B0C91    41              incecx
001B0C92    83C6 08         addesi, 8
001B0C95    83C7 08         addedi, 8
for8_expression:
001B0C98    3BD9            cmpebx, ecx
001B0C9A  ^ 7F E3           jgshort for8_body

Структура цикла достаточно тривиальна, например, счётчик цикла традиционно располагается в ecx, граничное значение счётчика располагается в ebx, что тоже традиционно. Примечательно здесь лишь то, что первая проверка условия цикла располагается сразу за инициализацией и выглядит не так, как основное условие цикла. Также очевидно, что чудес не бывает, и префикс REX недоступен ни в Protected Mode, ни в Compatible Mode, т.е. сравнение QWORD-блоками невозможно, поэтому сравниваются блоки размером DWORD. Однако из кода видно, что перед выполнением сравнений в регистры eax, edx загружаются соответствующие части первого массива, т.е. JIT-компилятор попытался произвести машинный код, максимально соответствующий исходному коду.

Бросается в глаза то, что компилятор здесь не стал применять строковую инструкцию CMPSD, а именно, её «короткую» форму, которая выполняет сравнение DWORD-блоков, размещённых по адресам esi и edi, выставляя соответствующие флаги. В этом случае размер машинного кода был бы меньше в несколько раз: команда mov здесь имеет размер 2 и 3 байта, команда cmp – 2 и 3 байта, а размер каждой команды CMPSD (в короткой форме) был бы всего 1 байт, т.е. для двух команд всего 2 байта. Это наводит на мысли о нежелании JIT-компилятора оптимизировать код.

Из приведённых листингов очевидно, что пара команд, первая из которых – пересылка в eax, является паттерном, которому компилятор следует неукоснительно.

Попытка сравнить блоки размером DWORD, выполняется, если такой объём остался
fourBytesComparing:
001B0C9C    F745 E8 0400000 testdwordptr [ebp-18], 00000004    ; ZF <-- (var3 & 4) == 0
001B0CA3    74 10           jeshort length_lowerThenFour

001B0CA5    8B06            moveax, [esi]

001B0CA7    3B07            cmpeax, [edi]
001B0CA9    74 04           jeshort dwords_equals              ; если блоки равны, переход к инкрименту регистров

001B0CAB    33C0            xoreax, eax
001B0CAD    EB 38           jmpshort return2

dwords_equals:
001B0CAF    83C6 04         addesi, 4
001B0CB2    83C7 04         addedi, 4

Попытка сравнить блоки размером WORD, выполняется, если такой объём остался
length_lowerThenFour:
001B0CB5    F745 E8 0200000 testdwordptr [ebp-18], 00000002    ; ZF <-- (var3 & 2) == 0
001B0CBC    74 10           jeshort length_lowerThenTwo

001B0CBE    0FBF06          movsxeax, wordptr [esi]

001B0CC1    66:3B07         cmpax, [edi]
001B0CC4    74 04           jeshort words_equals              ; если блоки равны, переход к инкрименту регистров

001B0CC6    33C0            xoreax, eax
001B0CC8    EB 1D           jmpshort return2

words_equals:
001B0CCA    46              incesi
001B0CCB    46              incesi
001B0CCC    47              incedi
001B0CCD    47              incedi

Попытка сравнить блоки размером BYTE, выполняется, если такой объём остался
length_lowerThenTwo:
001B0CCE    F745 E8 0100000 testdwordptr [ebp-18], 00000001   ; ZF <-- (var3 & 1) == 0
001B0CD5    74 0B           jeshort 001B0CE2

001B0CD7    0FB606          movzxeax, byteptr [esi]

001B0CDA    3A07            cmpal, [edi]
001B0CDC    74 04           jeshort 001B0CE2                 ; если блоки равны, переход к инкрименту регистров

001B0CDE    33C0            xoreax, eax
001B0CE0    EB 05           jmpshort return2

001B0CE2    B8 01000000     moveax, 1

Возврат результата и выброс исключения
return2:
001B0CE7    8D65 F4         leaesp, [ebp-0C]
001B0CEA    5B              popebx
001B0CEB    5E              popesi
001B0CEC    5F              popedi
001B0CED    5D              popebp
001B0CEE    C3              ret

JIT_RngChkFail:
001B0CEF    E8 C4B1DB61     call    clr.JIT_RngChkFail
001B0CF4    CC              int3

Анализ CRT функции memcmp()

Эта функция представляет интерес ещё и потому, что не просто сравнивает два буфера, а выясняет отношение между ними, а значит, она сложнее, чем те, что рассматривались ранее.

После подцепления дебагером я выяснил, что в память процесса по базовому адресу 0x76C20000 была загружена C:\Windows\SysWOW64\msvcrt.dll версии 7.0.7601.17744.


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

Первый же взгляд на препарируемую функцию меня несколько смутил: во-первых, перед стандартным прологом, в самом начале функции, располагалась странная инструкция, а во-вторых, размеры этой функции поражают воображение. Бросилось в глаза наличие «длинных» джампов, к тому же switch с 32 case’ами устрашает.

Странная инструкция
          76C37975   .  8BFF          mov     edi, edi                                   
          ; INT msvcrt.memcmp(buf1,buf2,count)
76C37977   .  55            pushebp
76C37978   .  8BEC          movebp, esp

Она копирует регистр сам в себя, не обновляя при этом никаких флагов, т.е. результат её выполнения нулевой, прямо как nop, но размером в 2 байта. Промотав код в окне отладчика, я обнаружил, что точно так же начинаются множество других функций. Благодаря блогу Рэймонда Чена объяснение нашлось быстро. Это действительно аналог nop.

ПРИМЕЧАНИЕ

It's a hot-patch point.

The MOV EDI, EDI instruction is a two-byte NOP, which is just enough space to patch in a jump instruction so that the function can be updated on the fly. The intention is that the MOV EDI, EDI instruction will be replaced with a two-byte JMP $-5 instruction to redirect control to five bytes of patch space that comes immediately before the start of the function. Five bytes is enough for a full jump instruction, which can send control to the replacement function installed somewhere else in the address space.

http://blogs.msdn.com/b/oldnewthing/archive/2011/09/21/10214405.aspx

Длинные джампы и очень большой switch
          Address   Hex dump              Command                                               Comments
75A9797C   .  8B7D 10           movedi, [ebp+10]                                 ; edi <-- length
75A9797F   .  8BC7              moveax, edi
75A97981   .  83E8 00           subeax, 0                                        ;
75A97984   .  0F84 E7070100     je      msvcrt.zeroResult_GoReturn                     ; (length == 0)=> {result <-- 0, goto return;}; часть кода опущена; часть кода опущена
75A979BC   .  83FF 1F           cmpedi, 1F                                       ; Switch (cases 1..1F, 32. exits)
75A979BF   .  77 5B             jashort msvcrt.75A97A1C
75A979C1   .  FF24BD 1F8AA975   jmpnear [edi*4+msvcrt.75A98A1F]
; часть кода опущена; часть кода опущена
75A98A1F   .  1C7AA975          dd      msvcrt.75A97A1C                               ; (00) Начало таблицы переходов, здесь jump на выход из функции
75A98A23   .  E88AA975          dd      msvcrt.75A98AE8                               ; (01)
75A98A27   .  CA8AA975          dd      msvcrt.75A98ACA                               ; (02)
75A98A2B   .  8C8BA975          dd      msvcrt.75A98B8C                               ; (03)
75A98A2F   .  0A7AA975          dd      msvcrt.75A97A0A                               ; (04)
75A98A33   .  088FA975          dd      msvcrt.75A98F08                               ; (05)
75A98A37   .  B88AA975          dd      msvcrt.75A98AB8                               ; (06)
75A98A3B   .  758BA975          dd      msvcrt.75A98B75                               ; (07)
75A98A3F   .  F479A975          dd      msvcrt.75A979F4                               ; (08)
75A98A43   .  238FA975          dd      msvcrt.75A98F23                               ; (09)
75A98A47   .  9F8AA975          dd      msvcrt.75A98A9F                               ; (0A)
75A98A4B   .  A18BA975          dd      msvcrt.75A98BA1                               ; (0B)
75A98A4F   .  DE79A975          dd      msvcrt.75A979DE                               ; (0C)
75A98A53   .  3A8FA975          dd      msvcrt.75A98F3A                               ; (0D)
75A98A57   .  FD8AA975          dd      msvcrt.75A98AFD                               ; (0E)
75A98A5B   .  ED8EA975          dd      msvcrt.75A98EED                               ; (0F)
75A98A5F   .  C879A975          dd      msvcrt.75A979C8                               ; (10)
75A98A63   .  518FA975          dd      msvcrt.75A98F51                               ; (11)
75A98A67   .  BA8EA975          dd      msvcrt.75A98EBA                               ; (12)
75A98A6B   .  6A98A975          dd      msvcrt.75A9986A                               ; (13)
75A98A6F   .  8990A975          dd      msvcrt.75A99089                               ; (14)
75A98A73   .  CD98A975          dd      msvcrt.75A998CD                               ; (15)
75A98A77   .  D58EA975          dd      msvcrt.75A98ED5                               ; (16)
75A98A7B   .  8598A975          dd      msvcrt.75A99885                               ; (17)
75A98A7F   .  1899A975          dd      msvcrt.75A99918                               ; (18)
75A98A83   .  E898A975          dd      msvcrt.75A998E8                               ; (19)
75A98A87   .  698FA975          dd      msvcrt.75A98F69                               ; (1A)
75A98A8B   .  9D98A975          dd      msvcrt.75A9989D                               ; (1B)
75A98A8F   .  3399A975          dd      msvcrt.75A99933                               ; (1C)
75A98A93   .  0099A975          dd      msvcrt.75A99900                               ; (1D)
75A98A97   .  848FA975          dd      msvcrt.75A98F84                               ; (1E)
75A98A9B   .  B598A975          dd      msvcrt.75A998B5                               ; (1F) Окончание таблицы переходов

В связи с большой трудоёмкостью анализа всей функции было решено не дизассемблировать её полностью, к тому же интерес представляли лишь две вещи:

Оценка накладных расходов на взаимодействие с платформой

Для оценки накладных расходов на вызов функции было решено провести трассировку кода от управляемого кода до начала самой функции. Для этого в начале функции была установлена точка останова, и после возврата из Thread.Sleep() начата трассировка. Однако результат первой попытки трассировки оказался неудачным: лог трассировки оказался слишком большим (около 100 тысяч строк), что, вероятно, говорило о том, что была выполнена DllMain(), также существовала вероятность, что был захвачен какой-то код CLR, возможно, код JIT-компилятора. Что именно там выполнялось, я не стал выяснять: это не представляло интереса, т.к. подобный стартовый код выполняется лишь единожды и на общую картину почти не влияет. Чтобы исключить этот код, я перед вызовом Thread.Sleep() вставил ещё один вызов memcmp() и заново произвёл трассировку. В результате чего получил чуть более ста строк.


Часть лога трассировки
main  00480AEA                    call0031C19C ESP=0016F368; часть кода опущена
main  clr.628C3B5F                call    near [eax+14] ESP=0016F248   ; (1); часть кода опущена
main  00480B87                    moveax, [ebp-1C]                   EAX=00313880
main  00480B8A                    moveax, [eax+14]                   EAX=0031391C
main  00480B8D                    mov     ecx, [eax] ECX=75A97975   ; (2)
main  00480B8F                    pushdwordptr [ebp+0C]              ESP=0016F328
main  00480B92                    pushdwordptr [ebp+8]               ESP=0016F324
main  00480B95                    pushedi ESP=0016F320
main  00480B96                    pushdwordptr [ebp-10]              ESP=0016F31C
main  00480B99                    movdwordptr [ebp-2C], 0
main  00480BA0                    mov     [ebp-28], esp
main  00480BA3                    movdwordptr [ebp-24], 480BB0
main  00480BAA                    movbyteptr [ebx+8], 0
main  00480BAE                    call    ecx ESP=0016F318   ; (3)
main  msvcrt.memcmp               movedi, edi
--------  Logging stopped

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

Оценка основного кода функции

В лучшем, наименее ресурсоёмком, случае функция обнаружит различие в первом же байте и вернёт результат. Именно в этом случае функция затратит наименьшее время. В худшем случае функция сравнит массивы целиком и не обнаружит различий. Очевидно, что во втором случае данные будут сравниваться блоками в цикле.

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

Модифицированная функция сравнения массивов
          private
          static
          bool CompareArraysWithPInvokeMethod()
{
   var result = true;
   for (int i = CountArrays - 1; i >= 0; i--)for (int j = 0; j < CountArrays; j++)
      {
         var tmp = ByteArrayCompareWithPInvoke(s_arrays[i], s_arrays[j]);

         result = result && tmp;
      }
   return result;
}

Первая трассировка (лучший случай)
main  msvcrt.memcmp               movedi, edi
main  msvcrt.75A97977             pushebp ESP=0041EC74
main  msvcrt.75A97978             movebp, esp EBP=0041EC74

main  msvcrt.75A9797A             pushesi ESP=0041EC70
main  msvcrt.75A9797B             pushedi ESP=0041EC6C

main  msvcrt.75A9797C             movedi, [ebp+10]                   EDI=00080000                     ; edi <-- count

main  msvcrt.75A9797F             moveax, edi EAX=00080000                     ; eax <-- edi
main  msvcrt.75A97981             subeax, 0                                                           ; if (eax == 0) {result <-- 0; return;}
main  msvcrt.75A97984             je      msvcrt.zeroResult_GoReturn
main  msvcrt.75A9798A             deceax EAX=0007FFFF
main  msvcrt.75A9798B             je      msvcrt.75A98C10
main  msvcrt.75A97991             deceax EAX=0007FFFE
main  msvcrt.75A97992             je      msvcrt.75A9E610
main  msvcrt.75A97998             deceax EAX=0007FFFD
main  msvcrt.75A97999             je      msvcrt.75A9E5DF
main  msvcrt.75A9799F             deceax EAX=0007FFFC
main  msvcrt.75A979A0             je      msvcrt.75A98BD2

main  msvcrt.75A979A6             movecx, [ebp+0C]                   ECX=034C53B8                    ; ecx <-- buf1
main  msvcrt.75A979A9             moveax, [ebp+8]                    EAX=05C41038                    ; eax <-- buf2
main  msvcrt.75A979AC             pushebxESP=0041EC68main  msvcrt.75A979AD             push    20                              ESP=0041EC64                    ; Очень необычный способ поместить число в регистрmain  msvcrt.75A979AF             pop     edx                             EDX=00000020, ESP=0041EC68;--------------------------------Начало цикла сравнения
main  msvcrt.75A979B0             cmpedi, edx
main  msvcrt.75A979B2             jae     msvcrt.75A993A7

main  msvcrt.75A993A7             movesi, [eax]                      ESI=4241403Fmain  msvcrt.75A993A9             cmp     esi, [ecx]main  msvcrt.75A993AB             jne     msvcrt.75AA80E7                                                 ; Обнаружено отличие

main  msvcrt.75AA80E7             movzxesi, byteptr [eax]             ESI=0000003F
main  msvcrt.75AA80EA             movzxebx, byteptr [ecx]             EBX=00000001main  msvcrt.75AA80ED             sub     esi, ebx                        ESI=0000003E                    ; Вычиление разницы между нулевыми байтами DWORD’аmain  msvcrt.75AA80EF             jne     msvcrt.75AA8178main  msvcrt.75AA8178             xor     ebx, ebx                        EBX=00000000                    ; Формирование результата в регистре ebx
main  msvcrt.75AA817A             testesi, esimain  msvcrt.75AA817C             setg    bl                              EBX=00000001main  msvcrt.75AA817F             lea     ebx, [ebx+ebx-1]
main  msvcrt.75AA8183             movesi, ebxESI=00000001
main  msvcrt.75AA8185             testesi, esi
main  msvcrt.75AA8187             jne     msvcrt.75A98AB1

main  msvcrt.75A98AB1             moveax, esiEAX=00000001
main  msvcrt.75A98AB3             jmp     msvcrt.75A97A1E

main  msvcrt.75A97A1E             popebxEBX=00852AE0, ESP=0041EC6C
main  msvcrt.return1              popediESP=0041EC70, EDI=034C53B8
main  msvcrt.75A97A20             popesiESP=0041EC74, ESI=034C53B0
main  msvcrt.75A97A21             popebpESP=0041EC78, EBP=0041ECC4
main  msvcrt.75A97A22             retESP=0041EC7C

Первое, что здесь бросается в глаза – то, что обработка частных случаев, т.е. случаев, когда параметр count лежит в пределах [0..4], сделана довольно необычно. Остаётся только гадать, является ли это результатом компиляции предложения switch или там действительно была заведена локальная переменная, которая декрементировалась на каждом шаге проверки. Однако отладочная информация утверждает, что это всё-таки был switch. С точки зрения оптимизации это вполне разумное действие, т.к. не выполняется инициализация цикла.

Второе, что сразу же бросилось в глаза – весьма необычный способ занесения числа 0x20 в регистр edx (через стек). Это очень похоже на какой-то артефакт компиляции, и явно говорит о том, что функция писалась не на ассемблере. Человек бы такого не написал, т.к. это бессмысленно: стек находится в памяти, а к ней обращения всегда медленнее, чем к регистрам. Рискну предположить, что это артефакт встраивания (inline).

После обнаружения различия двойных слов в буферах выполняется переход по адресу 0x75AA8178, где происходит загрузка первых байтов из исходных буферов в регистры esi и ebx по адресам, где было обнаружено различие. Затем следует вычисление разницы между этими байтами, и, если не обнаружено различий, то загружаются следующие байты, и так для всего двойного слова. Примечательно, что результат не зависит от номера байта, в котором обнаружено различие. Это видно из того, что код формирования результата для последнего байта в DWORD’е полностью идентичен коду аналогичного назначения для первого байта, приведённому выше в трэйслоге первой трассировки.

Последовательное сравнение байтов двойного слова
          Address   Hex dump          Command                                  Comments
75AA80E7   >  0FB630        movzxesi, byteptr [eax]
75AA80EA   .  0FB619        movzxebx, byteptr [ecx]
75AA80ED   .  2BF3          subesi, ebx                           ; Вычиление разницы между нулевыми байтами DWORD’а
75AA80EF   .- 0F85 83000000 jne     msvcrt.75AA8178                    ; Джамп на формирование результата в регистре ebx

75AA80F5   >  0FB670 01     movzxesi, byteptr [eax+1]
75AA80F9   .  0FB659 01     movzxebx, byteptr [ecx+1]
75AA80FD   .  2BF3          subesi, ebx
75AA80FF   .- 0F84 1EF9FEFF je      msvcrt.75A97A23; часть кода опущена75A97A23   >  0FB670 02     movzxesi, byteptr [eax+2]
75A97A27   .  0FB659 02     movzxebx, byteptr [ecx+2]
75A97A2B   .  2BF3          subesi, ebx
75A97A2D   .- 74 15         jeshort msvcrt.75A97A44; часть кода опущена75A97A44   >  0FB670 03     movzxesi, byteptr [eax+3]
75A97A48   .  0FB659 03     movzxebx, byteptr [ecx+3]
75A97A4C   .  2BF3          subesi, ebx
75A97A4E   .- 0F84 5F190000 je      msvcrt.75A993B3                    ; Джамп куда-то внутрь цикла75A97A54   .  33DB          xor     ebx, ebx                           ; Формирование результата в регистре ebx
75A97A56   .  85F6          testesi, esi75A97A58   .  0F9FC3        setg    bl75A97A5B   .  8D5C1B FF     lea     ebx, [ebx+ebx-1]
75A97A5F   .  8BF3          movesi, ebx
75A97A61   .- E9 4D190000   jmp     msvcrt.75A993B3; часть кода опущена
75A993B1   . |33F6          xoresi, esi75A993B3   > |85F6          testesi, esi
75A993B5   .-|0F85 F6F6FFFF jne     msvcrt.75A98AB1

Дублирование кода нехорошо, но не страшно, а вот последовательное сравнение байтов – не лучший способ сравнения двойных слов, тем более что номер байта на результат не влияет. Таким образом, уже видно, что этот код несколько странноват, возможно, потому, что исходники ещё более странноваты.

Первый взгляд на лог второй трассировки: за одну итерацию цикла сравниваются 8 двойных слов, и это хорошо: видно, что цикл развёрнут, а с другой стороны, после каждого сравнения идёт абсолютно одинаковый бесполезный код: в регистр esi заносится 0 и анализируется содержимое регистра esi. У меня нет никаких предположений о том, зачем это было сделано, однако есть предположение о том, как такое могло получиться: во-первых, это очень похоже на результат работы какого-то макроса, формировавшего ассемблерный код, а во-вторых, похоже, что майкрософтовский компилятор C не так хорош, как я об этом думал раньше.

Вторая трассировка (только цикл)
          ;--------------------------------Начало цикла сравнения
main  msvcrt.75A979B0             cmpedi, edx
main  msvcrt.75A979B2             jae     msvcrt.75A993A7

main  msvcrt.75A993A7             movesi, [eax]                      ESI=00000000
main  msvcrt.75A993A9             cmpesi, [ecx]
main  msvcrt.75A993AB             jne     msvcrt.75AA80E7
main  msvcrt.75A993B1             xor     esi, esimain  msvcrt.75A993B3             test    esi, esimain  msvcrt.75A993B5             jne     msvcrt.75A98AB1

main  msvcrt.75A993BB             movesi, [eax+4]
main  msvcrt.75A993BE             cmpesi, [ecx+4]
main  msvcrt.75A993C1             jne     msvcrt.75AA811F
main  msvcrt.75A993C7             xor     esi, esimain  msvcrt.75A993C9             test    esi, esimain  msvcrt.75A?993CB             jne     msvcrt.75A98AB1

main  msvcrt.75A993D1             movesi, [eax+8]
main  msvcrt.75A993D4             cmpesi, [ecx+8]
main  msvcrt.75A993D7             jne     msvcrt.75A97A9A
main  msvcrt.75A993DD             xor     esi, esimain  msvcrt.75A993DF             test    esi, esimain  msvcrt.75A993E1             jne     msvcrt.75A98AB1


main  msvcrt.75A993E7             movesi, [eax+0C]
main  msvcrt.75A993EA             cmpesi, [ecx+0C]
main  msvcrt.75A993ED             jne     msvcrt.75A97B1F
main  msvcrt.75A993F3             xor     esi, esimain  msvcrt.75A993F5             test    esi, esimain  msvcrt.75A993F7             jne     msvcrt.75A98AB1


main  msvcrt.75A993FD             movesi, [eax+10]
main  msvcrt.75A99400             cmpesi, [ecx+10]
main  msvcrt.75A99403             jne     msvcrt.75A97BA4
main  msvcrt.75A99409             xor     esi, esimain  msvcrt.75A9940B             test    esi, esimain  msvcrt.75A9940D             jne     msvcrt.75A98AB1


main  msvcrt.75A99413             movesi, [eax+14]
main  msvcrt.75A99416             cmpesi, [ecx+14]
main  msvcrt.75A99419             jne     msvcrt.75A97C29
main  msvcrt.75A9941F             xor     esi, esimain  msvcrt.75A99421             test    esi, esimain  msvcrt.75A99423             jne     msvcrt.75A98AB1


main  msvcrt.75A99429             movesi, [eax+18]
main  msvcrt.75A9942C             cmpesi, [ecx+18]
main  msvcrt.75A9942F             jne     msvcrt.75AA1172
main  msvcrt.75A99435             xor     esi, esimain  msvcrt.75A99437             test    esi, esimain  msvcrt.75A99439             jne     msvcrt.75A98AB1


main  msvcrt.75A9943F             movesi, [eax+1C]
main  msvcrt.75A99442             cmpesi, [ecx+1C]
main  msvcrt.75A99445             jne     msvcrt.75A97CFC
main  msvcrt.75A9944B             xor     esi, esimain  msvcrt.75A9944D             test    esi, esimain  msvcrt.75A9944F             jne     msvcrt.75A98AB1


main  msvcrt.75A99455             addeax, edx EAX=031353B8
main  msvcrt.75A99457             addecx, edx ECX=031353B8
main  msvcrt.75A99459             subedi, edx EDI=0007FFE0
main  msvcrt.75A9945B             jmp     msvcrt.75A979B0

Примечательно, что на больших объёмах тестовых данных этот код показал результат на ~10% хуже, чем код, использующий unsafe. Понятно, что основное время при сравнении массивов данных уходит на операции чтения из памяти, которая значительно медленнее, чем кэш процессора и, тем более, регистры. Но столь серьёзная разница, которую дали одни лишь операции с регистрами процессора, говорит о том, что оптимизации компилятора крайне важны. Рискну предположить, что тестирование на более слабом процессоре, например, на процессоре с меньшей тактовой частотой, дало бы значительно более существенную разницу.

Выводы

  1. Если надо быстро сравнивать маленькие (7 байт и меньше) массивы, берём наиболее очевидный способ (побайтное сравнение в цикле), большие — unsafe, а всё остальное от лукавого.
  2. Вокруг .Net и CLR ходит много легенд. Людей убедили в том, что JIT-компилятор генерирует хороший, оптимизированный код, что CLR «затачивает» машинный код под конкретный процессор. Это неправда. В теории JIT-компилятор способен творить чудеса оптимизации, но на практике не делает этого. Нужен быстрый код — берите C++-компилятор от Intel, а если нужно вообще максимум из железа выжать, то вооружайтесь руководством по оптимизации от одноимённой фирмы (AMD или Intel) и пишите на ассемблере.
  3. Анализ C-RunTime функции показывает, что компилятор не творит чудес, даже если это один из лучших C-компиляторов – MS VC. Цитата из статьи «О реализации метода оптимизации при компиляции» (http://rsdn.ru/article/optimization/optimization.xml): «Только если особый случай не найден, компилятор выполняет реализацию, рассчитанную на самый общий случай и потому потенциально менее эффективную».

Список литературы

  1. Intel® 64 and IA-32 Architectures Software Developer Manuals.CHAPTER 2. Intel ® 64 Architecture http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html.
  2. AMD64 Architecture Programmer’s Manual Volume 1: Application Programming CHAPTER 1 Long Mode
    http://amd-dev.wpengine.netdna-cdn.com/wordpress/media/2012/10/24592_APM_v11.pdf
  3. Intel® 64 and IA-32 Architectures Optimization Reference Manual. CHAPTER 3. Alignment http://www.intel.com/content/www/us/en/architecture-and-technology/64-ia-32-architectures-optimization-manual.html
  4. Windows Internals, Sixth Edition, Part 1, CHAPTER 5 Processes, Threads, and Jobs
  5. Windows Internals, Sixth Edition, Part 2, CHAPTER 10. Memory Management
  6. Intel® 64 and IA-32 Architectures Software Developer Manuals. CHAPTER 17. TIME-STAMP COUNTER
  7. MSDN Magazine 2005, May: Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects
    http://msdn.microsoft.com/en-us/magazine/cc163791.aspx#S6
  8. Joe Duffy, Professional .NET Framework 2.0 (Programmer to Programmer). Chapter 3: Inside the CLR, Just-In-Time (JIT) Compilation


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