Альтернативные реализации стандартных функций/подходов

 

NormalizeDouble

#define EPSILON (1.0e-7 + 1.0e-13)
#define HALF_PLUS  (0.5 + EPSILON)

double MyNormalizeDouble( const double Value, const int digits )
{
  // Добавление static ускоряет код в три раза (Optimize=0)!
  static const double Points[] = {1.0e-0, 1.0e-1, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5, 1.0e-6, 1.0e-7, 1.0e-8};

  return((int)((Value > 0) ? Value / Points[digits] + HALF_PLUS : Value / Points[digits] - HALF_PLUS) * Points[digits]);
}

ulong Bench( const int Amount = 1.0e8 )
{
  double Price = 1.23456;
  const double point = 0.00001;
  
  const ulong StartTime = GetMicrosecondCount();
  
  int Tmp = 0;
  
  for (int i = 0; i < Amount; i++)
  {
    Price = NormalizeDouble(Price + point, 5); // замените на MyNormalizeDouble и почувствуйте разницу
    
    // Если убрать, то общее время выполнения будет нулевым при любом Amount (Optimize=1) - круто! В варианте NormalizeDouble оптимизации такой не будет.  
    if (i + i > Amount + Amount)
      return(0);
  }
  
  return(GetMicrosecondCount() - StartTime);
}

void OnStart( void )
{
  Print(Bench());
    
  return;
};

 

Результат 1123275 и 1666643 в пользу MyNormalizeDouble (Optimize=1). Без оптимизации - быстрее раза в четыре (на память).


 

Если заменить

static const double Points[] = {1.0e-0, 1.0e-1, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5, 1.0e-6, 1.0e-7, 1.0e-8};

на switch-вариант, можно будет увидеть качество реализации switch в цифрах.

 

Рассмотрим почищенный вариант скрипта с NormalizeDouble:

#define EPSILON (1.0e-7 + 1.0e-13)
#define HALF_PLUS  (0.5 + EPSILON)
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
double MyNormalizeDouble(const double Value,const int digits)
  {
   static const double Points[]={1.0e-0,1.0e-1,1.0e-2,1.0e-3,1.0e-4,1.0e-5,1.0e-6,1.0e-7,1.0e-8};

   return((int)((Value > 0) ? Value / Points[digits] + HALF_PLUS : Value / Points[digits] - HALF_PLUS) * Points[digits]);
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
ulong BenchStandard(const int Amount=1.0e8)
  {
   double       Price=1.23456;
   const double point=0.00001;
   const ulong  StartTime=GetMicrosecondCount();
//---
   for(int i=0; i<Amount;i++)
     {
      Price=NormalizeDouble(Price+point,5);
     }
   
   Print("Result: ",Price);   // специально выводим результат, чтобы цикл не оптимизировался в ноль
//---
   return(GetMicrosecondCount() - StartTime);
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
ulong BenchCustom(const int Amount=1.0e8)
  {
   double       Price=1.23456;
   const double point=0.00001;
   const ulong  StartTime=GetMicrosecondCount();
//---
   for(int i=0; i<Amount;i++)
     {
      Price=MyNormalizeDouble(Price+point,5);
     }
   
   Print("Result: ",Price);   // специально выводим результат, чтобы цикл не оптимизировался в ноль
//---
   return(GetMicrosecondCount() - StartTime);
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   Print("Standard: ",BenchStandard()," msc");
   Print("Custom:   ",BenchCustom(),  " msc");
  }

Результаты:

Custom:   1110255 msc
Result:   1001.23456

Standard: 1684165 msc
Result:   1001.23456

Сразу замечания и объяснения:

  1. static тут нужен обязательно, чтобы компилятор вынес этот массив за пределы функции и не конструировал его на стеке при каждом вызове функции. Компилятор С++ делает тоже самое.
    static const double Points
  2. Чтобы компилятор не выкинул цикл из-за его бесполезности, надо воспользоваться результатами вычислений. Например, сделать Print переменной Price.

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

    Например, вызвать как MyNormalizeDouble(Price+point,10) и ловим ошибку:
    array out of range in 'BenchNormalizeDouble.mq5' (19,45)
    
    Метод ускорения путем отказа от проверок - это приемлемо, но не в нашем случае. Мы ведь обязаны обрабатывать любой ошибочный ввод данных.

  4. Добавим простое условие на индекс больше 8, причем для упрощения кода тип переменной digits заменим на uint, чтобы делать одно сравнение на >8 вместо дополнительного условия <0
    //+------------------------------------------------------------------+
    //|                                                                  |
    //+------------------------------------------------------------------+
    double MyNormalizeDouble(const double Value,uint digits)
      {
       static const double Points[]={1.0e-0,1.0e-1,1.0e-2,1.0e-3,1.0e-4,1.0e-5,1.0e-6,1.0e-7,1.0e-8};
    //---
       if(digits>8)
          digits=8;
    //---
       return((int)((Value > 0) ? Value / Points[digits] + HALF_PLUS : Value / Points[digits] - HALF_PLUS) * Points[digits]);
      }
    

  5. Запускаем код и... удивляемся!
    Custom:   1099705 msc
    Result:   1001.23456
    
    Standard: 1695662 msc
    Result:   1001.23456
    
    Ваш код еще больше обогнал стандартную функцию NormalizeDouble!

    Причем добавление условия даже уменьшило время (на самом деле уменьшение тут в пределах погрешности). Почему такая разница в скорости?

  6. Все дело в стандартной ошибке тестировщиков производительности.

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

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

  7. Начнем с распространения констант - это одна из важных ошибок, допущенных вами в этом тесте.

    У вас половина входных данных являются константами. Перепишем пример с учетом их распространения.

    ulong BenchStandard(void)
      {
       double      Price=1.23456;
       const ulong StartTime=GetMicrosecondCount();
    //---
       for(int i=0; i<1.0e8;i++)
         {
          Price=NormalizeDouble(Price + 0.00001,5);
         }
    
       Print("Result: ",Price);
    //---
       return(GetMicrosecondCount() - StartTime);
      }
    
    ulong BenchCustom(void)
      {
       double      Price=1.23456;
       const ulong StartTime=GetMicrosecondCount();
    //---
       for(int i=0; i<1.0e8;i++)
         {
          Price=MyNormalizeDouble(Price + 0.00001,5);
         }
    
       Print("Result: ",Price," ",1.0e8);
    //---
       return(GetMicrosecondCount() - StartTime);
      }
    
    После запуска ничего не изменилось - так и должно быть.

  8. Идем дальше - инлайним ваш код (наш NormalizeDouble заинлайнить нельзя)

    Вот во что превращается ваша функция в реальности после неминуемого инлайна. Экономия на call, экономия на извлечениях из массива, проверки удаляются за счет анализа констант:
    ulong BenchCustom(void)
      {
       double              Price=1.23456;
       const ulong         StartTime=GetMicrosecondCount();
    //---
       for(int i=0; i<1.0e8;i++)
         {
          //--- этот код полностью вырезается, так как у нас заведомо константа 5
          //if(digits>8)
          //   digits=8;
          //--- распространяем переменные и активно заменяем константы
          if((Price+0.00001)>0)
             Price=int((Price+0.00001)/1.0e-5+(0.5+1.0e-7+1.0e-13))*1.0e-5;
          else
             Price=int((Price+0.00001)/1.0e-5-(0.5+1.0e-7+1.0e-13))*1.0e-5;
         }
    
       Print("Result: ",Price);
    //---
       return(GetMicrosecondCount() - StartTime);
      }
    
    я не стал суммировать чистые константы, чтобы не терять время. все они гарантированно сворачиваются на этапе компиляции.

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

  9. Посмотрите на код, который вы в реальности начали тестировать из-за фиксированных исходных данных.

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


  10. Так как же надо тестировать на производительность?

    Понимая работу компилятора, нужно не дать ему применять предварительную оптимизацию и упрощения.

    Например, сделаем вариабельным параметр digits:

    #define EPSILON (1.0e-7 + 1.0e-13)
    #define HALF_PLUS  (0.5 + EPSILON)
    //+------------------------------------------------------------------+
    //|                                                                  |
    //+------------------------------------------------------------------+
    double MyNormalizeDouble(const double Value,uint digits)
      {
       static const double Points[]={1.0e-0,1.0e-1,1.0e-2,1.0e-3,1.0e-4,1.0e-5,1.0e-6,1.0e-7,1.0e-8};
    //---
       if(digits>8)
          digits=8;
    //---   
       return((int)((Value > 0) ? Value / Points[digits] + HALF_PLUS : Value / Points[digits] - HALF_PLUS) * Points[digits]);
      }
    //+------------------------------------------------------------------+
    //|                                                                  |
    //+------------------------------------------------------------------+
    ulong BenchStandard(const int Amount=1.0e8)
      {
       double       Price=1.23456;
       const double point=0.00001;
       const ulong  StartTime=GetMicrosecondCount();
    //---
       for(int i=0; i<Amount;i++)
         {
          Price=NormalizeDouble(Price+point,2+(i&15));
         }
    
       Print("Result: ",Price);   // специально выводим результат, чтобы цикл не оптимизировался в ноль
    //---
       return(GetMicrosecondCount() - StartTime);
      }
    //+------------------------------------------------------------------+
    //|                                                                  |
    //+------------------------------------------------------------------+
    ulong BenchCustom(const int Amount=1.0e8)
      {
       double       Price=1.23456;
       const double point=0.00001;
       const ulong  StartTime=GetMicrosecondCount();
    //---
       for(int i=0; i<Amount;i++)
         {
          Price=MyNormalizeDouble(Price+point,2+(i&15));
         }
    
       Print("Result: ",Price);   // специально выводим результат, чтобы цикл не оптимизировался в ноль
    //---
       return(GetMicrosecondCount() - StartTime);
      }
    //+------------------------------------------------------------------+
    //|                                                                  |
    //+------------------------------------------------------------------+
    void OnStart(void)
      {
       Print("Standard: ",BenchStandard()," msc");
       Print("Custom:   ",BenchCustom()," msc");
      }
    
    Запускаем и... получаем такой же по скорости результат как и раньше.

    Ваш код выигрывает как и раньше около 35%.

  11. Почему же так?

    От оптимизации мы все равно не спаслись из-за инлайнинга. Экономия в 100 000 000 вызовов с передачей данных через стек в нашу такую же по реализации функцию NormalizeDouble запросто может дать такое ускорение.

    Есть еще одно подозрение, что наш NormalizeDouble мы не вынесли в direct_call механизм при загрузке таблицы релокаций функций у MQL5 программы.

    С утра проверим и если это так, то перенесем в direct_call и проверим скорость заново.

Вот такое исследование NormalizeDouble.

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

 
fxsaber:

Если заменить

на switch-вариант, можно будет увидеть качество реализации switch в цифрах.

Вы путаете прямой индексированный доступ к статическому массиву по константному индексу (который вырождается в константу из поля) и switch.

switch не очень-то может конкурировать с таким случаем. У switch есть несколько часто употребимых оптимизаций вида:

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

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

 
Renat Fatkhullin:

Рассмотрим почищенный вариант скрипта с NormalizeDouble:

Результаты:


Сразу замечания и объяснения:

  1. static тут нужен обязательно, чтобы компилятор вынес этот массив за пределы функции и не конструировал его на стеке при каждом вызове функции. Компилятор С++ делает тоже самое.
При "Optimize=0" так и есть. При "Optimize=1" можно даже выкинуть - оптимизатор компилятор умен, как оказалось.
  1. Чтобы компилятор не выкинул цикл из-за его бесполезности, надо воспользоваться результатами вычислений. Например, сделать Print переменной Price.
Крутая фишка!
  1. В вашей функции есть ошибка - не проверяются границы digits, что легко может дать выход за пределы массива.

    Например, вызвать как MyNormalizeDouble(Price+point,10) и ловим ошибку:
    Метод ускорения путем отказа от проверок - это приемлемо, но не в нашем случае. Мы ведь обязаны обрабатывать любой ошибочный ввод данных.

  2. Добавим простое условие на индекс больше 8, причем для упрощения кода тип переменной digits заменим на uint, чтобы делать одно сравнение на >8 вместо дополнительного условия <0
Так, вроде, оптимальнее
double MyNormalizeDouble( const double Value, const uint digits )
{
  static const double Points[] = {1.0e-0, 1.0e-1, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5, 1.0e-6, 1.0e-7, 1.0e-8};
  const double point = digits > 8 ? 1.0e-8 : Points[digits];

  return((int)((Value > 0) ? Value / point + HALF_PLUS : Value / point - HALF_PLUS) * point);
}
  1. Все дело в стандартной ошибке тестировщиков производительности.

    При написании тестов нужно иметь в виду полный список оптимизаций, который может быть применен компилятором. Нужно четко осознавать, какие входные данные вы используете и как они будут уничтожены, когда вы пишите упрощенный пример теста.
  2. Так как же надо тестировать на производительность?

    Понимая работу компилятора, нужно не дать ему применять предварительную оптимизацию и упрощения.

    Например, сделаем вариабельным параметр digits:
Спасибо огромное за подробнейшее разъяснение, как правильно готовить замеры производительности компилятора! Действительно, не учел возможность оптимизации константы.

Вот такое исследование NormalizeDouble.

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

Да, такой результат - предмет гордости.
 
Renat Fatkhullin:

Вы путаете прямой индексированный доступ к статическому массиву по константному индексу (который вырождается в константу из поля) и switch.

switch не очень-то может конкурировать с таким случаем. У switch есть несколько часто употребимых оптимизаций вида:

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

Вот тут как раз такой случай упорядоченности.

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

Пробовал на 32-х битной системе. Там замена в примере выше на switch приводила к серьезным тормозам. На новой машине не проверял.
 
fxsaber:

Вот тут как раз такой случай упорядоченности.

Надо отдельно проверить, но позже.


Пробовал на 32-х битной системе. Там замена в примере выше на switch приводила к серьезным тормозам. На новой машине не проверял.

В каждом MQL5 есть на самом деле две скомпилированные программы: упрощенная для 32 бит и максимально оптимизированная под 64 бита. На 32 битной МТ5 вообще новый оптимизатор не применяется и код для 32 битных операционок такой же простой как для MQL4 в МТ4.

Вся эффективность компилятора, который может генерировать в десяток раз быстрее код только при исполнении в 64 битной версии МТ5: https://www.mql5.com/ru/forum/58241

Мы полностью сконцентриованы на 64 битных версиях платформы.

 

На тему NormalizeDouble есть такая ерунда

Форум по трейдингу, автоматическим торговым системам и тестированию торговых стратегий

Как последовательно перебрать перечисление?

fxsaber, 2016.08.26 16:08

В описании функции есть такое примечание

Это верно только для символов, которые имеют минимальный шаг цены 10^N, где N - целое и не положительное. Если минимальный шаг цены имеет другое значение, то нормализация ценовых уровней перед OrderSend является бессмысленной операцией, которая в большинстве случаев будет приводить к возврату false OrderSend.


Хорошо бы исправить устаревшие представления в справке.

NormalizeDouble полностью дискредитирована. Мало того, что тормозная реализация, так еще и бессмысленная на множестве биржевых символов (например, RTS, MIX и т.д.).

NormalizeDouble изначально создавался Вами для Order*-операций. В основном, для цен и лотов. Но появились нестандарные TickSize и VolumeStep. И функция просто устарела. Из-за этого пишут тормозной код. Пример из стандартной библиотеки
double CTrade::CheckVolume(const string symbol,double volume,double price,ENUM_ORDER_TYPE order_type)
  {
//--- check
   if(order_type!=ORDER_TYPE_BUY && order_type!=ORDER_TYPE_SELL)
      return(0.0);
   double free_margin=AccountInfoDouble(ACCOUNT_FREEMARGIN);
   if(free_margin<=0.0)
      return(0.0);
//--- clean
   ClearStructures();
//--- setting request
   m_request.action=TRADE_ACTION_DEAL;
   m_request.symbol=symbol;
   m_request.volume=volume;
   m_request.type  =order_type;
   m_request.price =price;
//--- action and return the result
   if(!::OrderCheck(m_request,m_check_result) && m_check_result.margin_free<0.0)
     {
      double coeff=free_margin/(free_margin-m_check_result.margin_free);
      double lots=NormalizeDouble(volume*coeff,2);
      if(lots<volume)
        {
         //--- normalize and check limits
         double stepvol=SymbolInfoDouble(symbol,SYMBOL_VOLUME_STEP);
         if(stepvol>0.0)
            volume=stepvol*(MathFloor(lots/stepvol)-1);
         //---
         double minvol=SymbolInfoDouble(symbol,SYMBOL_VOLUME_MIN);
         if(volume<minvol)
            volume=0.0;
        }
     }
   return(volume);
  }

Ну нельзя так топорно делать! Можно же в разы быстрее, забыв про NormalizeDouble

double NormalizePrice( const double dPrice, double dPoint = 0 )
{
  if (dPoint == 0) 
    dPoint = ::SymbolInfoDouble(::Symbol(), SYMBOL_TRADE_TICK_SIZE);

  return((int)((dPrice > 0) ? dPrice / dPoint + HALF_PLUS : dPrice / dPoint - HALF_PLUS) * dPoint);
}

И для того же volume тогда делать

volume = NormalizePrice(volume, stepvol);

Для цен делать

NormalizePrice(Price, TickSize)

Видится правильным добавить что-то подобное в качестве перегрузки стандартной NormalizeDouble. Где второй параметр "digits" будет не int, а double.

 

К 2016 году большинство С++ компиляторов пришло к одинаковым уровням оптимизации.

MSVC заставляет удивляться улучшениям с каждым апдейтом, а Intel C++ как компилятор слился - так и не вылечился от своих "internal error" на больших проектах.

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

 

По теме. Приходиться создавать альтернативы стандартным функциям, потому что они выдают иногда не то, что нужно. Вот пример альтернативы SymbolInfoTick

// Получение тика, который на самом деле вызвал крайнее событие NewTick
bool MySymbolInfoTick( const string Symb, MqlTick &Tick, const uint Type = COPY_TICKS_ALL )
{
  MqlTick Ticks[];
  const int Amount = ::CopyTicks(Symb, Ticks, Type, 0, 1);
  const bool Res = (Amount > 0);
  
  if (Res)
    Tick = Ticks[Amount - 1];
  
  return(Res);
}

// Возвращает в точности то, что SymbolInfoTick
bool CloneSymbolInfoTick( const string Symb, MqlTick &Tick )
{
  MqlTick TickAll, TickTrade, TickInfo;
  const bool Res = (MySymbolInfoTick(Symb, TickAll) &&
                    MySymbolInfoTick(Symb, TickTrade, COPY_TICKS_TRADE) &&
                    MySymbolInfoTick(Symb, TickInfo, COPY_TICKS_INFO));
  
  if (Res)
  {
    Tick = TickInfo;

    Tick.time = TickAll.time;
    Tick.time_msc = TickAll.time_msc;
    Tick.flags = TickAll.flags;
    
    Tick.last = TickTrade.last;
    Tick.volume = TickTrade.volume;    
  }
  
  return(Res);
}

Казалось бы, в тестере вызывай на каждом событии NewTick SymbolInfoTick и складывай volume-поле, чтобы узнать биржевой оборот. Ан нет, нельзя! Приходиться делать куда более логичную по смыслу MySymbolInfoDouble.

 
fxsaber:

На тему NormalizeDouble есть такая ерунда

NormalizeDouble изначально создавался Вами для Order*-операций. В основном, для цен и лотов. Но появились нестандарные TickSize и VolumeStep. И функция просто устарела. Из-за этого пишут тормозной код. Пример из стандартной библиотеки

Ну нельзя так топорно делать! Можно же в разы быстрее, забыв про NormalizeDouble

И для того же volume тогда делать

Для цен делать

Видится правильным добавить что-то подобное в качестве перегрузки стандартной NormalizeDouble. Где второй параметр "digits" будет не int, а double.

Можно оптимизировать все вокруг.

Это бесконечный процесс. Но в 99% случаев экономически невыгодный.