Безразклонени условия за цели числа — бързо, но могат ли да бъдат направени по-бързи?

Експериментирах със следното и забелязах, че дефинираното тук безразклонено „ако“ (сега с &-!! заместващо *!!) може да ускори определен код с тесни места с до (почти) 2 пъти на 64-битови цели на Intel с clang:

// Produces x if f is true, else 0 if f is false.
#define  BRANCHLESS_IF(f,x)          ((x) & -((typeof(x))!!(f)))

// Produces x if f is true, else y if f is false.
#define  BRANCHLESS_IF_ELSE(f,x,y)  (((x) & -((typeof(x))!!(f))) | \
                                     ((y) & -((typeof(y)) !(f))))

Имайте предвид, че f трябва да бъде разумно прост израз без странични ефекти, така че компилаторът да може да направи най-добрите си оптимизации.

Производителността силно зависи от процесора и компилатора. Представянето на „ако“ без разклонения е отлично със звън; Все още не съм намерил случаи, в които „if/else“ без разклонения е по-бърз.

Въпросът ми е: те безопасни и преносими ли са, както е написано (което означава, че гарантирано дават правилни резултати за всички цели) и могат ли да бъдат направени по-бързи?

Примерно използване на безразклонен if/else

Те изчисляват 64-битов минимум и максимум.

inline uint64_t uint64_min(uint64_t a, uint64_t b)
{
  return BRANCHLESS_IF_ELSE((a <= b), a, b);
}

inline uint64_t uint64_max(uint64_t a, uint64_t b)
{
  return BRANCHLESS_IF_ELSE((a >= b), a, b);
}

Примерно използване на безразклонно ако

Това е 64-битово модулно допълнение — то изчислява (a + b) % n. Версията с разклоняване (не е показана) страда ужасно от неуспешно предсказване на разклонения, но версията без разклонения е много бърза (поне с clang).

inline uint64_t uint64_add_mod(uint64_t a, uint64_t b, uint64_t n)
{
  assert(n > 1); assert(a < n); assert(b < n);

  uint64_t c = a + b - BRANCHLESS_IF((a >= n - b), n);

  assert(c < n);
  return c;
}

Актуализация: Пълен конкретен работещ пример за if без разклонения

По-долу е напълно работеща C11 програма, която демонстрира разликата в скоростта между разклонени и безразклонени версии на просто условно if, ако искате да го изпробвате на вашата система. Програмата изчислява модулно степенуване, което е (a ** b) % n, за изключително големи стойности.

За да компилирате, използвайте следното в командния ред:

  • -O3 (или каквото и да е високо ниво на оптимизация, което предпочитате)
  • -DNDEBUG (за деактивиране на твърдения, за скорост)
  • Или -DBRANCHLESS=0, или -DBRANCHLESS=1, за да укажете съответно разклонено или безразклонено поведение

В моята система ето какво се случва:

$ cc -DBRANCHLESS=0 -DNDEBUG -O3 -o powmod powmod.c && ./powmod
BRANCHLESS = 0
CPU time:  21.83 seconds
foo = 10585369126512366091

$ cc -DBRANCHLESS=1 -DNDEBUG -O3 -o powmod powmod.c && ./powmod
BRANCHLESS = 1
CPU time:  11.76 seconds
foo = 10585369126512366091

$ cc --version
Apple LLVM version 6.0 (clang-600.0.57) (based on LLVM 3.5svn)
Target: x86_64-apple-darwin14.1.0
Thread model: posix

И така, безразклонната версия е почти два пъти по-бърза от разклонената версия на моята система (3,4 GHz. Intel Core i7).

// SPEED TEST OF MODULAR MULTIPLICATION WITH BRANCHLESS CONDITIONALS

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include <time.h>
#include <assert.h>

typedef  uint64_t  uint64;

//------------------------------------------------------------------------------
#if BRANCHLESS
  // Actually branchless.
  #define  BRANCHLESS_IF(f,x)          ((x) & -((typeof(x))!!(f)))
  #define  BRANCHLESS_IF_ELSE(f,x,y)  (((x) & -((typeof(x))!!(f))) | \
                                       ((y) & -((typeof(y)) !(f))))
#else
  // Not actually branchless, but used for comparison.
  #define  BRANCHLESS_IF(f,x)          ((f)? (x) : 0)
  #define  BRANCHLESS_IF_ELSE(f,x,y)   ((f)? (x) : (y))
#endif

//------------------------------------------------------------------------------
// 64-bit modular multiplication.  Computes (a * b) % n without division.

static uint64 uint64_mul_mod(uint64 a, uint64 b, const uint64 n)
{
  assert(n > 1); assert(a < n); assert(b < n);

  if (a < b) { uint64 t = a; a = b; b = t; }  // Ensure that b <= a.

  uint64 c = 0;
  for (; b != 0; b /= 2)
  {
    // This computes c = (c + a) % n if (b & 1).
    c += BRANCHLESS_IF((b & 1), a - BRANCHLESS_IF((c >= n - a), n));
    assert(c < n);

    // This computes a = (a + a) % n.
    a += a - BRANCHLESS_IF((a >= n - a), n);
    assert(a < n);
  }

  assert(c < n);
  return c;
}

//------------------------------------------------------------------------------
// 64-bit modular exponentiation.  Computes (a ** b) % n using modular
// multiplication.

static
uint64 uint64_pow_mod(uint64 a, uint64 b, const uint64 n)
{
  assert(n > 1); assert(a < n);

  uint64 c = 1;

  for (; b > 0; b /= 2)
  {
    if (b & 1)
      c = uint64_mul_mod(c, a, n);

    a = uint64_mul_mod(a, a, n);
  }

  assert(c < n);
  return c;
}

//------------------------------------------------------------------------------
int main(const int argc, const char *const argv[const])
{
  printf("BRANCHLESS = %d\n", BRANCHLESS);

  clock_t clock_start = clock();

  #define SHOW_RESULTS 0

  uint64 foo = 0;  // Used in forcing compiler not to throw away results.

  uint64 n = 3, a = 1, b = 1;
  const uint64 iterations = 1000000;
  for (uint64 iteration = 0; iteration < iterations; iteration++)
  {
    uint64 c = uint64_pow_mod(a%n, b, n);

    if (SHOW_RESULTS)
    {
      printf("(%"PRIu64" ** %"PRIu64") %% %"PRIu64" = %"PRIu64"\n",
             a%n, b, n, c);
    }
    else
    {
      foo ^= c;
    }

    n = n * 3 + 1;
    a = a * 5 + 3;
    b = b * 7 + 5;
  }

  clock_t clock_end = clock();
  double elapsed = (double)(clock_end - clock_start) / CLOCKS_PER_SEC;
  printf("CPU time:  %.2f seconds\n", elapsed);

  printf("foo = %"PRIu64"\n", foo);

  return 0;
}

Втора актуализация: Intel срещу ARM производителност

  • Тестването на 32-битови ARM цели (iPhone 3GS/4S, iPad 1/2/3/4, както е компилирано от Xcode 6.1 с clang) разкрива, че безразклоненото „ако“ тук всъщност е около 2–3 пъти по-бавно< /em> отколкото троичния ?: за модулния код за степенуване в тези случаи. Така че изглежда, че тези безклонови макроси не са добра идея, ако е необходима максимална скорост, въпреки че могат да бъдат полезни в редки случаи, когато е необходима постоянна скорост.
  • На 64-битови ARM цели (iPhone 6+, iPad 5) безразклоненият „if“ работи със същата скорост като троичния ?: — отново както е компилиран от Xcode 6.1 с clang.
  • Както за Intel, така и за ARM (както е компилирано от clang), безразклоненият „if/else“ беше около два пъти по-бавен от троичния ?: за изчисляване на min/max.

person Todd Lehman    schedule 08.08.2015    source източник
comment
Искате да кажете, че те са по-бързи от f ? a: b?   -  person Oliver Charlesworth    schedule 08.08.2015
comment
Също така си струва да се отбележи, че f се оценява два пъти във втората версия, което може да има нежелани странични ефекти.   -  person Oliver Charlesworth    schedule 08.08.2015
comment
Също така имайте предвид, че ако a или b са NAN или безкрайност, ще се случат странни неща.   -  person Oliver Charlesworth    schedule 08.08.2015
comment
@OliverCharlesworth — (1) Да! Те могат да бъдат много по-бързи от f ? a : b. Виждал съм 2 пъти ускорения. (2) Да, трябва да внимавате да не правите нищо със странични ефекти, когато преминавате f. f трябва да бъде прост израз, в който случай всъщност не се оценява два пъти, тъй като съвременните компилатори са отлични за елиминирането на излишни подизрази. (3) a и b не могат да бъдат NaN, защото са предназначени да се използват само за цели числа.   -  person Todd Lehman    schedule 08.08.2015
comment
Може да е полезно да знаете, че изразът (uint32_t)(x|(-x))>>31 е еквивалентен на x==0? 0:1. Вижте тук за повече подробности.   -  person barak manos    schedule 08.08.2015
comment
Намирам това за наистина изненадващо; Бих очаквал авторите на компилатора да са направили нещо оптимално за това.   -  person Oliver Charlesworth    schedule 08.08.2015
comment
Още един трик: #define MIN(a,b) (a & (signed)((a-b)>>63)) | (b & ~(signed)((a-b)>>63)).   -  person barak manos    schedule 08.08.2015
comment
@OliverCharlesworth — Да, наистина е изненадващо! Хей, току-що актуализирах въпроса, за да включа пълна работеща примерна програма, която се култира сама, ако искате да я изпробвате на вашата собствена система. (Дори има вложен безклонов ако. :-)   -  person Todd Lehman    schedule 09.08.2015
comment
Виждам, че мразиш да пишеш _t.   -  person StackedCrooked    schedule 09.08.2015
comment
На моята машина, с gcc 4.9.2, версията без разклонения (16.6s) е малко по-бавна от версията с разклонения (15.5)   -  person dyp    schedule 09.08.2015
comment
Въпреки че мога да възпроизведа наблюденията на OP, когато използвам clang 3.6.0 (branchless 10.7s е почти 2 пъти по-бърз, отколкото с клонове 19.5s).   -  person dyp    schedule 09.08.2015
comment
@dyp — На една система, на която опитах това с gcc, всъщност беше два пъти по-лошо с branchless. Но сега пренаписах макросите, за да заменя умножението (*!!) с побитово логическо и (&-!!), и сега той работи със същата скорост за мен с gcc, независимо дали е с разклоняване или без разклоняване.   -  person Todd Lehman    schedule 09.08.2015
comment
Не е преносим, ​​защото зависи от представянето на -1, което е всички 1. Няма нищо в стандарта C, което да изисква това. По-конкретно, няма да работи на машина с допълнение на 1 или аритметика на знак-величина. Като казах това, не мога да цитирам съвременна машина, която не използва допълнение от 2 за цели числа.   -  person Gene    schedule 09.08.2015
comment
@Gene, ако това се прилага към неподписани типове, това няма нищо общо с представянето на знаци. -1, преобразуван в тип без знак, винаги е само 1.   -  person Jens Gustedt    schedule 10.08.2015
comment
@JensGustedt — Сигурен ли си? -1 в допълнение на две е 0xFFFFFFFFFFFFFFFF, но в допълнение на едно е 0xFFFFFFFFFFFFFFFE, нали? Струва ми се, че Gene е прав, в който случай използването на & срещу -1 не би било добро, ако целевата система използва едно допълнение.   -  person Todd Lehman    schedule 10.08.2015
comment
@JensGustedt Наистина, Тод Леман е прав. И в знакова величина, представянето на -1 е 0x80000000000000001. Така че поведението на макроса е недефинирано спрямо C стандарта. Ще работи добре в Java, където се изисква представяне на 2 на допълнение.   -  person Gene    schedule 11.08.2015
comment
Мисля, че и двамата разбрахте погрешно това, което казах. Говорех за прилагане на макроса към всеки неподписан тип. В C преобразуването се извършва по стойност, а не по представяне. -1 преобразуван във всеки неподписан тип винаги е максималната стойност на този неподписан тип, а това от своя страна е стойността с всички-1. Това не е въпрос на платформа и още по-малко на представяне на подписаните типове.   -  person Jens Gustedt    schedule 11.08.2015
comment
Дори и това да не беше по-бързо, все още можеше да бъде полезно в криптовалутата за намаляване на тайминг атаките.   -  person technosaurus    schedule 12.08.2015
comment
@barakmanos, с изключение на това, че изваждането във вашия макрос MIN може да препълни, напр. с MIN(INT_MAX, -1). Има дефиниции на минимума без разклонения, които избягват това изваждане (използвайки например израза a < b като цяло число).   -  person Maëlan    schedule 03.10.2019


Отговори (1)


Разбира се, това е преносимо, операторът ! гарантирано ще даде 0 или 1 като резултат. След това това се повишава до какъвто тип е необходим на другия операнд.

Както други отбелязаха, вашата версия if-else има недостатъка да оценява два пъти, но вие вече знаете това и ако няма страничен ефект, всичко е наред.

Това, което ме изненадва е, че казвате, че това е по-бързо. Бих си помислил, че съвременните компилатори сами извършват такъв вид оптимизация.

Редактиране: Тествах това с два компилатора (gcc и clang) и двете стойности за конфигурацията.

Всъщност, ако не забравите да зададете -DNDEBUG=1, версията 0 с ?: е много по-добра за gcc и прави това, което бих очаквал да направи. Той основно използва условни ходове, за да има цикъла без разклонения. В този случай clang не намира този вид оптимизация и прави някои условни скокове.

За версията с аритметика производителността за gcc се влошава. Всъщност, като видим какво прави, това не е изненадващо. Той наистина използва imul инструкции, а те са бавни. clang се справя по-добре тук. „Аритметиката“ всъщност оптимизира умножението и ги замени с условни ходове.

И така, за да обобщим, да, това е преносимо, но ако това доведе до подобрение или влошаване на производителността ще зависи от вашия компилатор, неговата версия, флаговете за компилиране, които прилагате, потенциала на вашия процесор ...

person Jens Gustedt    schedule 08.08.2015
comment
И аз така бих си помислил. Какъв компилатор(и) използвате? Използвах Clang. Току-що актуализирах нещата по-горе, за да включа напълно работеща C11 примерна програма, която сама се клоква, ако искате да я изпробвате на вашата собствена система. - person Todd Lehman; 09.08.2015
comment
Хей, страхотни, интересни резултати с gcc срещу clang. Да, уау, това наистина зависи от компилатора и целевия процесор. Сравнителният анализ/профилирането е от решаващо значение тук. В моя случай модулното умножение е тясно място в една част от кода ми, така че имам нужда да бъде възможно най-бързо, така че двукратното ускоряване си заслужава... но това със сигурност няма да е винаги така. - person Todd Lehman; 09.08.2015
comment
Между другото, пренаписах макросите, за да заменя умножението (*!!) с побитово логическо и (&-!!) и сега if изглежда работи със същата скорост за мен с gcc, независимо дали е с разклоняване или без разклоняване. - person Todd Lehman; 09.08.2015