Войти
ПрограммированиеСтатьиОбщее

Практическое применение SSE расширения. (3 стр)

Автор:

Непосредственно о примере

А теперь поговорим о, собственно, примере к статье. Перечислю то, что в нём плохо и чего не достает:

1  Нет сохранения вначале, установки  нового и восстановления предыдущего режима точности для сопроцессора (Демонстрация работы с сопроцессором не входит в "юрисдикцию" данной статьи, а примеры исходного кода приведены лишь для сравнения).

2  Нет детектирования поддержки SSE расширения.

3  Нет некоторых вспомогательных тестов и проверок в обсуждаемых примерах.

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

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

Работа с сопроцессором

Для работы с сопроцессором в библиотеке C/C++ доступны функции controlfp()/_controlfp(). С помощью них выполняются основные операции настройки над сопроцессором. Любители приключений могут изучить документацию от Intel и написать свои аналоги этих функций с использованием кооманды fldcw/fstcw/fnstcw.

Детектирование “Фич”

Код детектирования наличия и поддержки той или иной особенности процессора доступен в документации Intel и может выглядеть, к примеру, следующим образом: 

BOOL IsMMX    = FALSE;
BOOL IsSSE    = FALSE;
BOOL IsSSE2    = FALSE;
BOOL Is3DNOW  = FALSE;

void GetCPUProperties(void)
{
  DWORD dwEdx;
  BOOL bIsAMD = FALSE;

  // Некая отладочная функция 

  DEBUG_Printf("...расширенная информация\r\n");

  __asm 
  {
    mov  eax,1
    cpuid
    mov dwEdx,edx
  }

  DEBUG_Printf("...поддержка расширенных инструкций\r\n");

  if (dwEdx & (1<<23)) 
  {
    IsMMX = TRUE;
    DEBUG_Printf("   MMX\r\n");
  }

  if (dwEdx & (1<<25)) 
  {
    // SSE может быть запрещено OS, либо отсутствовать расширенная поддержка 
    // контекста (24 бит edx CPUID(1)) ! 

    if (dwEdx & (1<<24))
    {      
      __try
      {
        // попытаемся исполнить комманду...
        __asm xorps xmm0, xmm0
        
        IsSSE = TRUE;
        DEBUG_Printf("   SSE\r\n");             
      }
      __except(EXCEPTION_EXECUTE_HANDLER)
      {
        // отсутствие поддержки в случае исключения...
      }

      // поддержка SSE2 (Без поддержки SSE не исполнится)

      if (dwEdx & (1<<26)) 
      {
        __try
        {
          // попытаемся исполнить комманду...
          __asm xorpd xmm0, xmm0
        
          IsSSE2 = TRUE;
          DEBUG_Printf("   SSE2\r\n");
        }
        __except(EXCEPTION_EXECUTE_HANDLER)
        {
          // отсутствие поддержки в случае исключения...
        }
      }
    }
  }

  // AMD расширение CPUID...  
  
  __asm 
  {
    mov eax,0x80000000
    cpuid
    cmp eax,0x80000000
    jc notamd
    mov  eax,0x80000001
    cpuid
    mov dwEdx,edx
    mov  bIsAMD,1
    notamd:
  }

  if (bIsAMD)
  {
    // Поддержка MMX обязательна !

    if ((IsMMX) && (dwEdx & (1<<31)))
    {
      Is3DNOW = TRUE;
      printf("   3DNOW!\n");
    }
  }

  return;
}

Для детектирования возможностей используется ассемблерная инструкция CPUID. Через регистр EAX для нее передается тип запрашиваемой информации или, как я далее по тексту буду ее называть, функция. Например, поместив в регистр EAX - 0x00 (просто ноль), а затем, вызвав CPUID, мы получим следующий результат. В регистрах EBX, EDX и ECX будет возвращено по четыре байта строки производителя. Например, содержимое этих регистров после вызова CPUID на моем P4 2ГГц выглядит следующим образом:

EBX =  0x756E6547  или ‘uneG’

EDX =  0x49656E69  или ‘Ieni’
ECX =  0x6C65746E  или ‘ntel’
что в итоге образует строку ‘GenuineIntel’

Функция 0x01 для CPUID запрашивает общую информацию по процессору. Не буду углубляться в дебри документации от Intel - нам нужен лишь регистр EDX. В него помещается двойное слово флагов. Каждый бит этого слова отвечает за поддержку той или иной особенности процессором. Например, как ты уже догадался, исходя из примера, если бит 23 равен единице, процессор поддерживает MMX расширение.
[tr=code]На заметку:

При вызове нулевой функции CPUID в EAX возвращается максимально поддерживаемый номер функции. Резонным было бы до выполнения первой функции проверить - доступна ли она вообще, удостоверившись, что содержимое EAX >= 0x01 после вызова нулевой функции.

[trtd]

Зоркий читатель сразу же обратит внимание на структуры try/catch в исходном коде. Зачем нам понадобилось “отлавливать” исключения? Кроме того, что поддержка SSE определяется двумя “взведенными» битами (25 и 24 биты), она должна быть доступна в самой операционной системе. Проверить это достаточно легко - просто попытаться исполнить ту или иную инструкцию в блоке try/catch и перехватить исключение, в случае каких либо проблем.

Чего не хватает в этой функции? Конечно! Хорошим тоном было бы определить, поддерживается ли сама инструкция CPUID процессором (Да, да, да - а затем определить поддерживается ли проверка поддержки инструкции CPUID :) Для тех, кто воспринял это серьезно, поясняю - это была шутка). Дело в том, что  Intel ввел поддержку CPUID с процессоров Pentium, а также некоторых 486 процессоров. Кто-то может сказать – вряд ли кто-нибудь будет использовать мою программу на 386 процессоре, но лично я все таки стараюсь избегать такого рода допущений вообще. За кодом детектирования милости просим в Intel Developer Guide, а я предложу вариант проще:

__try
{
// попытаемся исполнить комманду...
  __asm 
  {
    mov  eax,1
    cpuid
    mov dwEdx,edx
  }  
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  // отсутствие поддержки в случае исключения - покинем функцию
  return;
}

Вернемся к примеру статьи. В нем реализовано несколько простейших операций, а именно:
·  Скалярное произведение векторов (dot product).
·  Векторное произведение векторов (cross product).
·  Перемножение матриц.

Практически все операции реализованы в трех вариантах: Си код (pure), код для сопроцессора написанный вручную на ассемблере (fpu) и код с использованием SSE. Си код используется как для сравнения результатов, так и для демонстрации формулы для людей забывших, что такое скалярное произведение векторов или умножение матриц.

Несколько слов об оптимизации кода компилятором VC. Не смотря на часто встречающиеся ошибки в реализации оптимизации для MS VC, он генерирует достаточно производительный код. Если пытливый читатель сравнит код сгенерированный на pure варианты функций в примере с написанными в ручную fpu аналогами то практически не заметит разницы - скорее всего будет различаться порой лишь порядок следования инструкций.

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

pure dp : 0.000003 result: 10.000000

fpu  dp : 0.000002 result: 10.000000
sse  dp : 0.000003 result: 10.000000
pure cp : 0.000003 result: (-4.000000 8.000000 -4.000000 0.000000)
fpu  cp : 0.000003 result: (-4.000000 8.000000 -4.000000 0.000000)
sse  cp : 0.000002 result: (-4.000000 8.000000 -4.000000 0.000000)
sse  mmm: 0.000006 result:
[0.000000 1.000000 2.000000 3.000000]
[4.000000 5.000000 6.000000 7.000000]
[8.000000 9.000000 10.000000 11.000000]
[12.000000 13.000000 14.000000 15.000000]
sseb mmm: 0.000014 result:
[0.000000 1.000000 2.000000 3.000000]
[4.000000 5.000000 6.000000 7.000000]
[8.000000 9.000000 10.000000 11.000000]
[12.000000 13.000000 14.000000 15.000000]
ssei mmm: 0.000008 result:
[0.000000 1.000000 2.000000 3.000000]
[4.000000 5.000000 6.000000 7.000000]
[8.000000 9.000000 10.000000 11.000000]
[12.000000 13.000000 14.000000 15.000000]
pure mmm: 0.000012 result:
[0.000000 1.000000 2.000000 3.000000]
[4.000000 5.000000 6.000000 7.000000]
[8.000000 9.000000 10.000000 11.000000]
[12.000000 13.000000 14.000000 15.000000]

[tr=code]На заметку:

Если вывод программы не умещается на один экран несложно перенаправить вывод в файл например так: test.exe > result.txt

[trtd]

Приведенные выше результаты были получены на P IV 2ГГц. Следует отметить, что каждый вариант функции, будь то компилированный Си код, fpu или sse вариант, вызывался 128 раз для одних и тех же исходных данных и время исполнения (в секундах) в результате дано именно исходя из этого количества вызовов операций. Причина этого кроется в том, что время, затрачиваемое на исполнение одного вызова функции, скажем скалярного произведения векторов, настолько мало, что не может быть зафиксировано. В целом, все функции находятся в равных условиях, поскольку для каждого из вариантов будет справедливо кэширование данных процессором.

[tr=code]На заметку:

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

[trtd]

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

Для высокоточных замеров использовалась собственная функция  _benchmark() из sysdebug.cpp, в которую передается замеряемая функция-обертка:

//...
printf("sse  dp : %f result: %f\n",_benchmark(_dp_sse),dp_sse(v1,v2));
//...

[tr=code]На заметку:

Команда printf() (как и многие другие функции вывода на экран Си рантайма) достаточно громоздка и могла бы очень сильно влиять на результаты замеров. Именно поэтому в самой _benchmark() отсутствует какой либо вывод.

[trtd]

Рассмотрим саму функцию:

double _benchmark(void(*pfunc)())
{
  LARGE_INTEGER freq,start,stop;
  int tp;

  QueryPerformanceFrequency(&freq);

  if (freq.QuadPart == 0)
  {
    return 0.0f;
  }

  tp = GetThreadPriority(GetCurrentThread());

  if (tp == THREAD_PRIORITY_ERROR_RETURN)
  {
    return 0.0f;
  }

  SetThreadPriority(GetCurrentThread(),THREAD_PRIORITY_TIME_CRITICAL);
  QueryPerformanceCounter(&start);
  (*pfunc)();
  QueryPerformanceCounter(&stop);
  SetThreadPriority(GetCurrentThread(),tp);

  return (double)(stop.QuadPart - start.QuadPart)/(double)(freq.QuadPart);
}

Правильность функционирования этой функции напрямую зависит от наличия (или его эмуляции) в системе счетчика высокого разрешения (high performance counter). Данный счетчик выполняет в секунду определенное количество "тиков", которое можно получить вызовом Win32 QueryPerformanceFrequency(). Если возвращаемое значение этой функции равно нулю - в системе отсутствует поддержка счетчика высокого разрешения.

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

Сохранив текущий приоритет исполнения программы в tp, мы устанавливаем максимальный приоритет, запоминаем стартовое количество тиков вызовом QueryPerformanceCounter() и вызываем тестируемую функцию. После ее мы запоминаем конечное число тиков, которое затем используем для расчета прошедшего времени и восстанавливаем предыдущий приоритет приложения.

Расчет количества прошедшего времени в секундах выполняется по следующей формуле:

результирующее время в секундах = (кол-во конечных тиков - количество стартовых тиков)/ количество тиков в секунду

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

После рассмотрения механизма замера результатов в программе рассмотрим сами результаты. Как уже обратил внимание читатель сравнительный результат скалярного произведения векторов выходит не в пользу SSE:

pure dp : 0.000003

fpu  dp : 0.000002
sse  dp : 0.000003

Время исполнения 128 вызовов SSE варианта проигрывают аналогам за 0.000001 секунды. Дело в том, что достаточно простые операции хотя и будут выполнятся быстрее с использованием SSE варианта тратят больше времени на подготовку данных и возврат результатов операции. Однако для векторного произведения векторов мы имеем небольшой прирост, в сравнении с FPU и Си вариантами:

pure cp : 0.000003

fpu  cp : 0.000003
sse  cp : 0.000002

Умножение матриц реализовано в нескольких вариантах:
·  sse - базовый вариант с использованием SSE инструкций.
·  sseb - "bad" - пример неправильной структуры SSE реализации.
·  ssei - intrinsic - интринсики - как уже упомяналось ранее один из способов использования SSE (и других расширений) без применения программирования на ассемблере.
·  pure - исходный текст на языке Си.

Результат говорит сам за себя:

sse  mmm: 0.000006 

sseb mmm: 0.000014
ssei mmm: 0.000008
pure mmm: 0.000012

Правильно построенный SSE вариант в 2 раза обошел его аналог на языке Си.

Страницы: 1 2 3 4 5 Следующая »

#ASM, #SSE, #оптимизация

26 августа 2003

Комментарии [9]