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

Коротко обо мне. Меня зовут Макс, и так получилось, что я вот уже 10 лет, почти с самого начала своей карьеры, занимаюсь оптимизирующими компиляторами. Я начинал в Intel, потом перешёл в Azul Systems, год провёл в Cadence и вернулся обратно, всё это время занимаясь компиляторными оптимизациями для Java, C++ и нейросетевых моделей. На момент написания статьи у меня чуть за 900 патчей в LLVM, большинство из них посвящено цикловым оптимизациям.

За это время я провёл десятки собеседований на позиции как интернов, так и инженеров сеньорного уровня, и довольно часто люди, приходя на эти собеседования, многих вещей не знают или знают поверхностно. И я подумал: а мог бы я написать такой цикл статей, чтобы человек, прочитав их, узнал бы всю ту базу, которая, на мой собственный взгляд, необходима начинающему компиляторному инженеру? Очень бы хотелось, чтобы новичку в этой области можно бы было дать один (относительно небольшой по объёму) набор текстов, чтобы он получил оттуда всё необходимое для старта. Это не перевод, текст оригинальный, поэтому в нём могут быть ошибки и неточности, которые я буду рад исправить, если вы мне их укажете.

Итак, поехали.

Что такое SSA-форма

Наверное, все, кто хоть немного сталкивался с компиляторами, в курсе, что они обычно делают оптимизации не на исходном коде (написанном на С++, Java или, прости господи, на Haskell), а на некотором внутреннем представлении, которые обычно основаны на графах. Многие в курсе, что есть такая вещь, как AST (абстрактное синтаксическое дерево), которая вполне годится для некоторых этапов работы компилятора, но оптимизировать AST - достаточно неудобно (хотя и возможно, особенно если речь идёт о простых локальных оптимизациях). К тому же, конструкции AST так или иначе привязаны к языку, и если стоит задача построения универсального оптимизирующего компилятора для нескольких языков, то оно становится для этих целей совсем не пригодным.

Современные компиляторы решают эти проблемы путём добавления ещё одного уровня абстракции - промежуточного представления, или IR (англ. Intermediate Representation). Видов IR существует несколько, но мы подробно остановимся на тех, что основаны на SSA-форме (англ. Single Static Assignment form). Опыт и практика показали, что на них многие оптимизации делаются легко и приятно, и именно на ней основан LLVM IR, который лежит в основе компиляторов языков C++ (clang), Rust, Objective-C, Haskell, Kotlin Native, тысячи их. Есть даже компилятор Falcon в Azul Prime VM, который делает это для Java. Наверное, это не самый совершенный IR, какой может быть, но это лучшее, что есть у нашего человечества. Здесь и далее все примеры будут иллюстрироваться именно на LLVM IR, хотя это не единственный IR, основанный на SSA.

Итак, у Single Static Assignment формы есть следующие свойства:

  1. Программа состоит из инструкций, которые могут использовать (use) и определять (def) новые значения. Каждое определяемое значение имеет уникальное имя и не может быть переопределено.

  2. Инструкции привязаны к базовым блокам, образующим граф потока управления, или CFG (англ. Control Flow Graph), и исполняются при входе в соответствующий блок.

  3. Инструкция может использовать только те значения, которые гарантированно вычислены к моменту её исполнения.

  4. Для того, чтобы слить значения из разных ветвей CFG, используется специальная инструкция, называемая Φ-узлом (англ. Phi node). Это не русская "Ф", это греческая "фи".

Теперь давайте пройдёмся по этим пунктам и объясним каждый из них.

Значения определены лишь однажды

Рассмотрим простую функцию без каких-либо условных переходов. У нас есть просто линейный кусок кода, который проводит некоторые действия над переменными. Пример на С++:

int ssa_example(int a, int b) {
    int sum = a + b;
    sum = sum + 1;
    a++;
    sum += a;
    sum += b;
    return sum;
}

В этой программе у нас есть 3 переменные (a, b, sum), причём некоторые из них несколько раз меняют свои значения. В SSA-форме запрещено переопределять значения переменных. Чтобы представить себе, как это работает, вообразите, что заказчик (злобный тимлид, коварные рептилоиды) запретил вам в целях безопасности использовать какие-либо переменные, кроме const (но при этом все те же операции должны производиться над такими же значениями в том же порядке). С таким требованием вы бы переписали эту программу примерно вот так:

int ssa_example(const int a_0, const int b_0) {
    const int sum_0 = a_0 + b_0;
    const int sum_1 = sum_0 + 1;
    const int a_1 = a_0 + 1;
    const int sum_2 = sum_1 + a_1;
    const int sum_3 = sum_2 + b_0;
    return sum_3;
}

Просто всякий раз, когда нам нужно было переприсвоить переменную (чего мы теперь не можем делать) мы должны определить новую переменную и в дальнейшем пользоваться ей. С точки зрения С++, выглядит довольно многословно и бредово, и ни один человек в здравом уме так не будет писать. Но ведь мы стараемся не для человека, а для компилятора!

В таком виде программа имеет ценное преимущество: если вы видите использование одной и той же переменной в разных местах программы, вы точно знаете, что имеете дело с одним и тем же значением. Никакая инструкция ни в каком другом месте программы не могла его изменить. Рассмотрим известную оптимизацию "Удаление общих подвыражений", или CSE (англ. Common Subexpression Elimination). Представьте, что ваша программа написана на обычном С++, и где-то в двух разных местах вы видите код:

  int x = a + b;
...
  int y = a + b;

Можете ли вы не вычислять y, а вместо него везде использовать ранее посчитанное значение x? Да, но только в случае, если между этими двумя точками никто не поменял значения a или b. Если ваша программа записана в SSA-форме, то этот факт у вас есть автоматически, а в противном случае вам потребуется какой-то анализ, который пройдёт по инструкциям между этими двумя точками и поймёт, менялись ли a и b. Это не единственная причина, почему оптимизаторы любят SSA, и о них мы поговорим как-нибудь в другой раз. Пока просто примем на веру, что это удобно.

Напоследок, вот как аналогичная программа будет выглядеть на LLVM IR, который соответствует SSA-форме:

define i32 @ssa_example(i32 %a_0, i32 %b_0) {
    %sum_0 = add i32 %a_0, %b_0;
    %sum_1 = add i32 %sum_0, 1;
    %a_1 = add i32 %a_0, 1;
    %sum_2 = add i32 %sum_1, %a_1;
    %sum_3 = add i32 %sum_2, %b_0;
    ret i32 %sum_3;
}

Думаю, всё достаточно интуитивно и очень похоже на С++ с константными переменными. Написать второй раз %sum_2 = ... было бы семантической ошибкой.

Базовые блоки и CFG

Базовый блок - это линейный кусок кода, который исполняется последовательно, без передачи управления каким-либо другим инструкциям изнутри блока. Вы не можете ни прыгнуть в середину базового блока, ни из середины блока: зайдя в его начало, вы исполняете его весь целиком до конца*, и в конце переходите в другой блок. Фактически, любая конструкция для описания условного перехода (if, switch, goto, do-while, for, ...) приводит к появлению новых базовых блоков.

Представим ориентированный граф, вершины которого - это базовые блоки, а дуги соответствуют переходам между ними. Это и есть наш CFG. При этом один (входной, entry) блок в нём является особенным: с него начинается исполнение. Рассмотрим функцию на С++:

int cfg_example(int a, int b) {
    if (a + 1 < b) {
        a += b;
    } else {
        do {
            b *= a;
        } while (b < 100);
    }
    return (a + 7) * (b + 10);
}

Вот как будет выглядеть её CFG:

Содержимое блоков мы обсудим позже, важно то, что здесь видно, как выглядит ветвление и как выглядит цикл. Читатель, знакомый с языком ассемблера, наверное, заметил аналогию между этим представлением и лейблами/jmp-инструкциями, и эта аналогия совершенно верная. В текстовом виде LLVM IR выглядит очень похоже на чуть-чуть более высокоуровневый ассемблер, и лейблам соответствуют имена базовых блоков.

define i32 @cfg_example(i32 noundef %arg, i32 noundef %arg1) {
bb:
  %add = add nsw i32 %arg, 1
  %icmp = icmp slt i32 %add, %arg1
  br i1 %icmp, label %bb2, label %bb4
bb2:                                              ; preds = %bb
  %add3 = add nsw i32 %arg1, %arg
  br label %bb6
bb4:                                              ; preds = %bb4, %bb
  %phi = phi i32 [ %mul, %bb4 ], [ %arg1, %bb ]
  %mul = mul nsw i32 %phi, %arg
  %icmp5 = icmp slt i32 %mul, 100
  br i1 %icmp5, label %bb4, label %bb6
bb6:                                              ; preds = %bb4, %bb2
  %phi7 = phi i32 [ %add3, %bb2 ], [ %arg, %bb4 ]
  %phi8 = phi i32 [ %arg1, %bb2 ], [ %mul, %bb4 ]
  %add9 = add nsw i32 %phi7, 7
  %add10 = add nsw i32 %phi8, 10
  %mul11 = mul nsw i32 %add10, %add9
  ret i32 %mul11
}

Φ-функция

На рисунке выше видно, что в зависимости от значения входных параметров мы могли пойти по разным путям. Например, при некоторых входных данных мы могли пройти по пути %bb %bb2 %bb6, а могли - по пути %bb %bb4 %bb4 %bb4 %bb4 %bb6.

Давайте взглянем на исходную программу. Если мы зашли в if, то у нас поменяется значение a (т.е. мы объявим новое SSA-значение для a, в данном случае %add3). При этом блок %bb4 никогда не исполнялся. В то же время, если мы зашли в else, то поменялось значение b (для него мы создали инструкцию %mul), но при этом мы не заходили в %bb2.

В то же время, в заключительном блоке %bb6 нам нужны значения и для a, и для b. Интуитивно все понимают, что нельзя использовать результат той инструкции, которая не исполнялась, этого значения просто нигде нет. И нужно как-то выразить тот факт, что значения a и b могли измениться, а могли и не измениться в зависимости от того, как мы пришли в финальный блок. Более того, в цикле значение b могло поменяться несколько раз, и это тоже нужно как-то выразить.

Здесь мы сталкиваемся с понятием Φ-функции и соответствующей инструкцией (буду её называть phi-нодой по привычке). Они всегда вычисляются в начале базового блока по следующим правилам:

  1. Φ-функция содержит набор пар вида [значение, блок-предшественник]. Для каждой такой пары значение обязано быть доступным (т.е. эта инструкция точно исполнилась) в соответствующем блоке-предшественнике.

  2. Если мы приходим в данный блок из некоторого предшественника, то значение phi-ноды равно соответствующему значению из пары.

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

Таким образом, инструкцию:

  %phi7 = phi i32 [ %add3, %bb2 ], [ %arg, %bb4 ]

следует читать так: "Если мы пришли в данный блок из блока %bb2, то %phi7 равно %add3, а если мы пришли из %bb4 - то %phi7 равно %arg". Нетрудно заметить, что %arg - это стартовое значение переменной a, а %add3 - это значение a после захода в if. Это полностью соответствует семантике изначальной программы на С++.

Аналогичным образом следует понимать и Phi-ноду, моделирующую изменение b в цикле:

bb4:                                              ; preds = %bb4, %bb
%phi = phi i32 [ %mul, %bb4 ], [ %arg1, %bb ]
%mul = mul nsw i32 %phi, %arg
%icmp5 = icmp slt i32 %mul, 100
br i1 %icmp5, label %bb4, label %bb6

Если мы вошли в данный цикл впервые из блока %bb, то значение %phi равно %arg1 (т.е. стартовому значению b). Если же мы пришли сюда из %bb4 (то есть прошли по обратной дуге цикла), то %phi равна результату умножения на a (которое равно %arg). Заметьте также, что в самом умножении фигурирует не %arg1 (стартовое значение b), а именно %phi (текущее значение b). Так, несмотря на запрет переприсваивать значения, SSA-форма может моделировать многократно меняющиеся значения. Классический пример - счётчики в for-циклах, которые также моделируются phi-нодой (её ещё называют индукционной переменной, и об этом мы поговорим как-нибудь в другой раз).

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

int swap_example(int a, int b, unsigned n) {
  for (unsigned i = 0; i < n; i++) {
    int swap = a;
    a = b;
    b = swap;
  }
  return a;
}

Если вы скомпилируете её при помощи clang, то увидите что-то вроде:

define dso_local noundef i32 @swap_example(i32 noundef %arg, i32 noundef %arg1, i32 noundef %arg2) local_unnamed_addr #0 {
bb:
  br label %bb3
bb3:                                              ; preds = %bb3, %bb
  %phi = phi i32 [ %arg1, %bb ], [ %phi5, %bb3 ]
  %phi4 = phi i32 [ 0, %bb ], [ %add, %bb3 ]
  %phi5 = phi i32 [ %arg, %bb ], [ %phi, %bb3 ]
  %add = add i32 %phi4, 1
  %icmp = icmp eq i32 %phi4, %arg2
  br i1 %icmp, label %bb6, label %bb3
bb6:                                              ; preds = %bb3
  ret i32 %phi5
}

При внимательном рассмотрении тут есть кое-что, что кажется странным и часто сбивает с толку тех, кто забывает о последнем свойстве Phi-нод. Давайте разберём SSA-значения, которые мы тут видим:

  1. Пара %phi4 и %add моделирует счётчик цикла и используется для сравнения с n.

  2. %phi5 моделирует a, %phi моделирует b.

Возникает сразу два вопроса:

  1. Куда делась переменная swap? Почему для неё нет никакой инструкции или хотя бы Phi-ноды?

  2. Нет ли здесь ошибки? Ведь %phi использует в качестве одного из входов %phi5, и наоборот!

  %phi = phi i32 [ %arg1, %bb ], [ %phi5, %bb3 ]
...
  %phi5 = phi i32 [ %arg, %bb ], [ %phi, %bb3 ]

С первой итерацией всё понятно: тут значения равны стартовым значениям, т.е. параметрам функции: %phi = b,%phi5 = a. Но что происходит, если мы идём на вторую итерацию? Если мы сначала присвоим %phi = %phi5 = b, а потом %phi5 = %phi = b, то обе фи-ноды получат одно и то же значение. Если мы переставим их местами и будем следовать той же логике, то они обе станут равны a, а это совсем не то, чего мы хотим.

Правильная же интерпретация тут состоит в том, что Phi-ноды используют именно значения на входе в блок, а не те, которые как бы уже вычисляются в блоке (т.е. не другие ранее вычисленные Phi). Таким образом правильно сказать так: при проходе по обратной дуге %phi станет равно тому, чему было равно %phi5 на входе в блок (т.е. в данном случае a - на второй итерации), а %phi5 станет равно тому, чему было равно %phi на входе в блок (т.е. b - на второй итерации). В таком случае эти две инструкции действительно меняются значениями каждые две итерации, и swap оказывается просто не нужен. Он не представляет никакое новое значение.

На самом деле, Phi-ноды - это самая сложная и самая объёмная часть при любом разговоре об оптимизациях. Им будут посвящены следующие тексты. В том числе я расскажу о том, как формализовать понятия "доступности" значений в той или иной точке, что такое доминирование и как оно помогает найти места для вставки Phi-нод.

На сегодня всё. Благодарю за внимание, и до новых встреч!

* Если только там не будет чего-то типа System.exit(), на котором всё закончится, но для нашего рассказа это неважно.

Комментарии (39)


  1. xortator Автор
    16.05.2023 07:57
    +1

    Небольшое замечание по моему определению SSA формы (спасибо читателям, которые это заметили): на самом деле, оно, возможно, слишком уж строгое. Существуют реализации IR на основе SSA, в которых CFG в явном виде не существует, по крайней мере до какого-то момента. Одним из них является IR в Graal VM. Там всё равно есть финоды, и на фундаментальном уровне всё очень похоже, но мне проще объяснять основные принципы на примере LLVM IR, который имеет явный CFG.

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


  1. demoth
    16.05.2023 07:57
    +4

    Спасибо за статью! Давно хотел разобраться в SSA хотя бы немного, но всё руки не доходили. Понял (надеюсь, что правильно) наконец как работают Ф-функции. На самом деле, оказалось даже проще, чем мне казалось. В этом примере, где Ф-ноды зависят от других Ф-нод, сразу понял что дело в том, что значения из предыдущего блока берутся и нет никакой разницы в порядке вычислений внутри блока.


    1. xortator Автор
      16.05.2023 07:57
      +4

      Спасибо, мне приятно. :)
      На самом деле, я совсем не успел осветить расстановку фи-нод (как работают -- понятно, но где вставлять -- вопрос не такой тривиальный). В последующих текстах надеюсь обстоятельно это объяснить.


  1. sgzmd
    16.05.2023 07:57

    Спасибо за статью! Понятно о сложных вещах - это верный путь.


  1. VladimirFarshatov
    16.05.2023 07:57

    Спасибо за статью, появился ряд вопросов, если не трудно, то интересно прочесть ответы, в связи с тем что SSA это как-бы "RISC с бесконечным числом регистров" и с 3-адресной арифметикой:

    1. Умеют ли бэк-энды оптимизорать код из трех-адресной, скажем в одно или двух адресную или даже стековую арифметику (Java Jit в частности - стековый)?

    2. Как могут быть реализованы виртуальные таблицы методов, на отличной от типовых ветвлений архитектурах, например "табличной" (внутренняя регистровая таблица переходов)

    3. Реализация switch конструкций, где как-бы "данные" (регистры) становятся точками переходов, как?

    Возможно вопросы нубские, прошу прощения. Компиляторами занимался уж сильно давно (до 1990-го)..


    1. xortator Автор
      16.05.2023 07:57
      +7

      Умеют ли бэк-энды оптимизорать код из трех-адресной, скажем в одно или двух адресную или даже стековую арифметику (Java Jit в частности - стековый)?

      Ну, строго говоря, LLVM-инструкции не всегда трёхадресные, там иногда вообще нет def'а, или же аргумент всего один. Но если коротко - да, умеют, это задача instruction selector'а и register allocator'а. Я не шибко большой эксперт конкретно в этой теме, но если есть такой запрос, то чего-нибудь написать смогу.

      Java JIT в Фалконе переводит джава-байткод в LLVM IR и оптимизирует уже его, таким образом стековость там исчезает уже на этапе парсинга. Потом, на этапе кодогенерации, это превращается в обычный asm (например, x86). С2, насколько я знаю, занимается примерно тем же самым, только там Sea of nodes вместо LLVM IR.

      Как могут быть реализованы виртуальные таблицы методов, на отличной от типовых ветвлений архитектурах, например "табличной" (внутренняя регистровая таблица переходов)

      Я не уверен, что понял вопрос. Если речь о том, как моделируется виртуальный call, то для этого есть специальная инструкция, которая просто зовёт другую фукнцию. Она стоит где-нибудь в середине блока и не рассматривается как часть CFG данной функции. У неё просто есть сайд-эффект, говорящий о том, что вот из этой инструкции мы, например, можем и не вернуться обратно (а уйти в бесконечный цикл или вообще жёстко прервать программу). Реализована же она с помощью обычной таблицы виртуальных вызовов.

      Реализация switch конструкций, где как-бы "данные" (регистры) становятся точками переходов, как?

      switch аналогичен инструкции br, просто у br аргумент типа i1 и всего два константных варианта перехода (по true и false). Switch в LLVM разрешает аргумент любого целочисленного типа и большее количество переходов (но все они также идут по константным значениям). Подробно можно почитать здесь:https://llvm.org/docs/LangRef.html#switch-instruction


      1. VladimirFarshatov
        16.05.2023 07:57

        Спасибо, нет возможности плюсануть


  1. AVaTar123
    16.05.2023 07:57

    Только что смотрел и анализировал ассемблерный листинг большой программы на Си, скомпилированный свежим gcc с опцией -O3. Сначала было трудно, почти невозможно, понять в нём логику управления потоком исполнения команд. Потом попробовал -O2, -O1, -O0 и стал немного разбираться, как всё это устроено - стало полегче... А сейчас, после статьи, многое стало вообще понятно. Приятно чувствовать себя более разбирающимся в предмете своего интереса. Хотя я ассемблерный текст смотрел просто из любопытства - хотел посмотреть как ассемблерная вставка в сишной inline-функции вставилась...
    Спасибо за статью, на моём не особенно глубоком уровне понимания оптимизации - зашло на ура!
    Может быть на основе вашего опыта, вы можете дать какие-то общие рекомендации по стилю (архитектуре?) написания программ на Си?


    1. xortator Автор
      16.05.2023 07:57

      Спасибо за отзыв, я рад, если моя статья правда помогает людям. :)

      Что касается стиля написания программ на тех или иных языках - думаю, на этот счёт написано немало книг, и всё об одном и том же. Можете почитать "Совершенный код" и иже с ним, плюс книгу банды четырёх про паттерны, ну и следуйте тем конвенциям, которые есть в вашем проекте. Если нет никаких - то заведите какие-нибудь (лучше всего, опять же, слизать с какого-нибудь качественного опенсорсного проекта :) ).


      1. AVaTar123
        16.05.2023 07:57

        Спасибо за рекомендации, но я уже пользуюсь теми, что предназначены для лучшего понимания человеками. Я имел в виду стиль, способствующий глубокой автоматической оптимизации. Или таковой не существует?
        Более конкретно - что-то из области:
        "не создавайте много промежуточных переменных, а лучше лепите всё в одно выражение или делайте оператор в операторе (по возможности)"; или "не ходите много по меткам, а лучше избавьтесь от них", "вместо констант используйте #define, а лучше перечисления" (ну или наоборот). Чтобы это способствовало оптимизации, так сказать.


  1. xortator Автор
    16.05.2023 07:57
    +1

    Небольшое замечание по моему определению SSA формы (спасибо читателям, которые это заметили): на самом деле, оно, возможно, слишком уж строгое. Существуют реализации IR на основе SSA, в которых CFG в явном виде не существует, по крайней мере до какого-то момента. Одним из них является IR в Graal VM. Там всё равно есть финоды, и на фундаментальном уровне всё очень похоже, но мне проще объяснять основные принципы на примере LLVM IR, который имеет явный CFG.

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


  1. vvzvlad
    16.05.2023 07:57
    +3

    Напишите, пожалуйста побольше о том, что дает IR для оптимизаций потом.


    1. xortator Автор
      16.05.2023 07:57

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


  1. nikitanazarov
    16.05.2023 07:57

    Огромное спасибо за статью! С нетерпением жду продолжения серии!


  1. RranAmaru
    16.05.2023 07:57

    Очень интересно! Давно интересуюсь компиляторами, в студенчестве даже написал парочку простеньких, но до оптимизаций там дело не дошло. С нетерпением жду продолжения. Спасибо.


  1. adeshere
    16.05.2023 07:57

    Вопрос-оффтопик про ошибку оптимизации

    К теме статьи не относится, но вдруг Вы сможете подсказать? У меня есть программа на фортране, которую я собираю компилятором Интел. При выключенной оптимизации она работает корректно, а при включении ключей /MP /O2 /QaxSSE3 /QxSSE3 /Qparallel совершенно обычное присвоение с преобразованием типов

    R8=I8

    (64-битное целое преобразуется в 64-битное число с плавающей точкой) ИНОГДА (очень редко! - один раз за десятки миллионов вызовов) я получаю R8=Nan в случайных местах. Пришлось даже сделать подпорку - вместо простого присвоения я теперь вызываю функцию, которая проверяет результат и в случае R8=Nan начинает добавлять-вычитать единичку из I8, пока не найдет такое значение, которое конвертируется корректно. Увы, но эту подпорку я могу вставить только в собственный код, а при вызове встроенных функций баг все равно происходит...

    И второй интересный нюанс: баг возникает, только если где-то в моей программе есть запрос системного времени, например:

    call getdat(i4Year, i4Month, i4Day)

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

    Ну и третий нюанс = баг возникает только на некоторых процессорах. На других программа с оптимизацией работает корректно. (Список тех и других лежит в файле FORTRAN_URAND_BUG.doc).

    Впервые я обнаружил этот баг (Nan появлялся в генераторе случайных чисел) чуть больше года назад. Тогда же я задал этот вопрос на Хабре, и получил целую кучу советов от разных людей. Но раскрутить эту проблему до конца (найти причину бага и как его устранить) мы так и не смогли. Кроме того, объем собранной по багу информации намного превысил те 10000 символов, которые отведены для вопроса. Поэтому подробности пришлось вынести в отдельный файл FORTRAN_URAND_BUG.doc.

    P.S. Сборка программы:

    Ключи компиляции

    <Tool Name="VFFortranCompilerTool" SuppressStartupBanner="true" DebugInformationFormat="debugEnabled" MultiProcessorCompilation="true" GenAlternateCodePaths="codeForSSE3" UseProcessorExtensions="codeExclusivelySSE3" Parallelization="true" BufferedIO="true" EnableEnhancedInstructionSet="codeArchSSE3" FixedFormLineLength="fixedLength132" ErrorLimit="60" DebugParameter="debugParameterAll" WarnDeclarations="true" WarnUnusedVariables="true" WarnIgnoreLOC="true" WarnTruncateSource="true" WarnInterfaces="true" ByteRECL="true" InitLocalVarToNAN="true" LocalSavedScalarsZero="true" ModulePath="$(INTDIR)/" ObjectFile="$(INTDIR)/" PdbFile="$(OutDir)\vc90.pdb" Traceback="true" BoundsCheck="true" UninitializedVariablesCheck="true" RuntimeLibrary="rtMultiThreadedDebug" GlobalOptimizations="false"/>

    <Tool Name="VFLinkerTool"
    OutputFile="$(OUTDIR)/my.exe" LinkIncremental="linkIncrementalNo" SuppressStartupBanner="true" IgnoreDefaultLibraryNames="LIBCMT.LIB" GenerateDebugInformation="true" ProgramDatabaseFile="$(OUTDIR)/Lib_Test.pdb" SubSystem="subSystemConsole" />

    Настройки Floating-Point
    Ключи компиляции
    Ключи компиляции

    P.P.S. В любом случае, спасибо за статью! Я про компиляторы почти ничего не знаю (общаюсь с ними только как пользователь). Но для расширения кругозора всегда стараюсь читать подобные материалы, даже если понимаю не все. В этот раз понял довольно много - было очень интересно (и полезно ;-)


    1. IvanPetrof
      16.05.2023 07:57
      +2

      Был у меня похожий случай на Борландовском компиляторе c++. программа падала в странных местах с ошибками (то ли NAN, то ли Division by zero, точно не помню). Но помню, что связано было с плавающей точкой и падала она в том месте, где таких ошибок просто не могло произойти. Дебаг не помогал. Добавление отладочной выдачи внезапно "вылечивало" баг и он пропадал.

      В итоге, как я понял, дело было в том, что компилятор, видимо в силу оптимизаций, не всегда очищал регистр флагов процессора. И получалось так, что какая-то операция пыталась работать с плавающей точкой (в моём случае это было чтение из неинициализированной переменной типа double). Иногда там оказывался мусор и процессор взводил флаг некорректности числа. Но т.к. Операция простого чтения значения не предполагала, что что-то могло пойти не так (не совершались никакие арифметические операции), то далее компилятор не вставлял инструкции проверки этого флага и не сбрасывал его. А вот потом следовала безобидная операция (например, присвоение нормального значения в тип double). После этой операции, компилятор по всем своим правилам лез в регистр флагов, чтобы убедиться, что всё прошло хорошо и внезапно обнаруживал там флаг некорректной работы с Double! И на этом месте радостно вываливался в исключение. И естественно при этом показывал пальцем на безобидную инструкцию.


      1. adeshere
        16.05.2023 07:57

        Добавление отладочной выдачи внезапно "вылечивало" баг и он пропадал.

        Да, у меня очень похоже.

        Если вставить оператор print сразу после оператора присвоения Real_8=Integer_8, то Nan-ы не появляются. И некоторые другие операторы, использующие значение Real_8, тоже купируют баг, если их поставить сразу же после присвоения. При этом он может в одном месте исчезнуть, а в другом появиться...

        Но если бы это были просто флаги, то откуда связь с запросом системного времени? Если время не запрашивать, то бага не будет...

        А еще у меня проблема осложняется тем, что обычно у меня все ряды данных с пропусками, и Nan - это совершенно легальное значение при расчетах. Поэтому обнаружить их "самозарождение" я могу только по косвенным признакам уже после окончания вычислений. Например, если в исходном сигнале Nan-ов не было, а после прибавления к каждому значению ряда некоторой константы они вдруг появились. Поэтому заметить этот баг мне было непросто. Сперва я это явление обнаружил при вызове ГСЧ (когда Nan-ов в принципе быть не должно). Потом обнаружил то же самое при вычислении БПФ (для такого расчета я все пропуски в сигнале заранее заполняю). А уже потом начал целенаправленно тестить, и тут оказалось, что и некоторые другие операции небезгрешны...


        1. IvanPetrof
          16.05.2023 07:57
          +1

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

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


          1. adeshere
            16.05.2023 07:57

            Спасибо за совет, идея понятна. Но я пока не понимаю, как прочитать регистр флагов. Готовой функции для этого в моем компиляторе вроде бы нет (я не нашел). А asm-вставки я последний раз делал в DOS-фортране... умеет ли это компилятор от Интел, я пока не знаю. Быстрым поиском в справке я ничего не нашел. Возможно, что не умеет... а скомпилировать нужную функцию отдельно и прицепить на этапе линковки мне пока сложновато.


        1. Refridgerator
          16.05.2023 07:57
          +1

          Nan — это совершенно легальное значение при расчетах
          Использовать NaN в качестве маркера отсутствия данных — плохая идея. Потому что:
          1) его битовое представление разное для разных типов float,
          2) процессор работает с ним очень медленно, прям очень,
          3) проверку на NaN часто делают через сравнение переменной с самой собой, а оптимизирующий компилятор может счесть такое сравнение избыточным и выкинуть его.


          1. adeshere
            16.05.2023 07:57

            Использовать NaN в качестве маркера отсутствия данных — плохая идея

            А что, есть что-то лучше?

            Изначально у меня вместо Nan была предопределенная собственная константа. При этом после каждого вычисления приходилось проверять - не совпадает ли результат с этой константой, и корректировать его, если вдруг совпадает. В конечном счете я пришел к выводу, что удобнее (и быстрее) использовать стандартную константу IEEE_QUITE_NAN, которая самопроизвольно появляться вроде бы не должна. Скорость работы программы в результате не изменилась (мои тесты даже небольшое ускорение показали), а логика стала проще: значение проверяется на Nan не при вычислении, а только перед использованием. Это уменьшает число проверок и алгоритм легче читается. А перед использованием мне значение в любом случае надо на Nan проверять, т.к. пропуск - это вполне легальные данные, и их наличие - это не аварийная, а вполне штатная ситуация. Например, при подсчете среднего по N измерениям я суммирую только не-пропуски, а потом либо делю сумму на количество не-пропусков M, и возвращаю число, либо возвращаю Nan, если процент пропусков (N-M)/N превышает некоторый известный предел P (где P является одним из обязательных параметров функции). И аналогично при любых других вычислениях.

            Ну и проверку на NaN я делаю не вручную, а вызовом функции isNun(). Так что компилятор ее заведомо не выкидывает ;-) Это же фортран, он на такие ситуации специально заточен ;-)


            1. Refridgerator
              16.05.2023 07:57
              +1

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


              Я сам тоже иногда NaN использую, но только когда могу полностью контролировать формат и процесс. QUITE в IEEE_QUITE_NAN означает, что исключения выкидываться не будут. Но для FPU QUITE обеспечивается флагами на глобальном уровне (о чём я уже говорил), а вовсе не самой константой.


              Хорошая практика — это сделать новый тип из double и bool, в котором можно заодно и выход за пределы контролировать, и точность вычислений, и точность преобразований, и поведение при нештатных ситуациях явно прописать. Если у вас временные ряды — туда же можно и штамп времени засунуть, и вообще не факт, что в этом случае имеет смысл фиксировать пропущенное значение.


              1. adeshere
                16.05.2023 07:57

                Хорошая практика — это сделать новый тип из double и bool, в котором можно заодно и выход за пределы контролировать, и точность вычислений, и точность преобразований, и поведение при нештатных ситуациях явно прописать. Если у вас временные ряды — туда же можно и штамп времени засунуть, и вообще не факт, что в этом случае имеет смысл фиксировать пропущенное значение.

                В идеальном сферическом мире такая практика, наверное, неплоха. Только вот какая производительность будет у этого типа? Если что, у меня гигабайтные ряды - обычное дело. При том, что АЦП обычно 24- или даже 16-битные, поэтому данные при обработке хранятся, как Real*4 (а в базе вообще иногда ужимаются в Real*2). Надстройка из bool либо увеличит потребный размер памяти вдвое, либо катастрофически замедлит скорость из-за проблем с выравниванием. Я уж не говорю про штамп даты, который в моем случае потребует минимум 8 байт на каждое 4-байтное значение (пакет обрабатывает сигналы с частотой дискретизации от 1МГц до нескольких лет, и с длиной до миллионов лет. Хотя конечно, сигналы с высокой частотой всегда намного короче ;-).

                А точность вычислений я и так контролирую - она у меня в конфиге прописывается и затем везде проверяется, чтобы случайно деления на ноль или переполнения не случилось - там, где это потенциально возможно. К примеру, в конфиге есть параметр Zеro, который говорит, что все числа, меньшие +-Zеro, надо считать нулем. Поэтому деление на такое число у меня вернет Nun, и т.д. Сейчас эти "ручные" проверки, наверно, можно было бы как-то оптимизировать, но у меня они были сделаны еще в DOS фортране, и с тех пор рефакторятся по-минимуму (работает - не трогай). Хотя всякие функции типа nearest или huge я, конечно, тоже цепляю, по мере того, как они в языке (компиляторе) появляются...

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


                1. Refridgerator
                  16.05.2023 07:57

                  Так если у вас выход с АЦП 16/24-битный — то зачем преобразовывать его в 32/64-битный float? Откуда берутся пропущенные значения, если выход с АЦП всегда имеет конкретное целочисленное значение? Если данные пишутся через равные промежутки времени — то конечно, привязывать индивидуальный штамп времени к каждому значению смысла нет. Если нет — то вовсе не обязательно писать его вместе с датой, дата же не будет меняться через каждую секунду (у меня, например, это количество миллисекунд от начала месяца или года в UINT32).


                  1. adeshere
                    16.05.2023 07:57

                    Так если у вас выход с АЦП 16/24-битный — то зачем преобразовывать его в 32/64-битный float?

                    Тут все просто. Выход с АЦП идет в условных единицах, а работать-то хочется в градусах, миливольтах и пр. Это только кажется ерундой, а на самом деле эрогономически очень важно.Особенно если это разведочный анализ, и в обработке всегда участвует человек, а не просто какие-то формальные алгоритмы. Для этого данные надо пересчитывать по различным формулам, иногда не очень простым. А еще мне при обработке всегда нужен однородный сигнал, вне зависимости от того, что там где-то какой-то шунт поменяли. Это все делается на этапе первичной обработки еще до загрузки в базу. Поэтому в базе по-любому будут real-значения. Только, конечно, не 64-битные (при 24-битном АЦП это бессмысленно), а 32 или 16 бит. (Для краткости я их называю real*4 и real*2).

                    Откуда берутся пропущенные значения, если выход с АЦП всегда имеет конкретное целочисленное значение? 

                    Во-вторых, АЦП работает не всегда. Сплошь и рядом бывают технические перерывы и сбои, а также всякое техобслуживание. Ну и просто что-то может выйти из строя. Или прибор зашкаливает, так как в сигнале есть сильный дрейф (выбрали слишком высокую чувствительность при настройке), а вернуть его в рабочий диапазон некому. Это же не лаборатория, где все под контролем, а какая-то полевая точка, которая обслуживается в лучшем случае раз в неделю, а в худшем - раз в несколько месяцев.

                    Во-вторых, в экспериментальных данных достаточно регулярно появляется всякий брак. Иногда это помеха, иногда аппаратурный сбой. Я твердо придерживаюсь той точки зрения, что подобные "аномалии" надо заменять Nan-ами, а не какими-то искусственными (вымышленными) значениями. А затем обрабатывать с учетом этого факта.

                    дата же не будет меняться через каждую секунду (у меня, например, это количество миллисекунд от начала месяца или года в UINT32

                    Да, это возможный вариант... только вот в году 3*Е+7 сек, или 3Е+10мс. Поэтому даже для мс-ряда придется дополнительно хранить номер года. Который в моем случае может достигать очень больших величин (палеоклимат и др). А еще и шаг опроса может быть задан не в мс, а в мкс. В общем, 4 байт никак не хватает, если хочется сделать универсальную хронологию. Я вот недавно столкнулся с угрозой переполнения 8-битного счетчика при синхронизации скважности (там сначала умножение, а потом деление - пришлось специальные проверки прикручивать и в сложных случаях в два этапа считать).


                    1. Refridgerator
                      16.05.2023 07:57

                      Тут все просто. Выход с АЦП идет в условных единицах, а работать-то хочется в градусах, миливольтах и пр.
                      Так не обязательно работать с данными именно в том виде, как они хранятся у вас на магнитной ленте или перфокарте. Хорошей практикой считается не смешивать логики. Хранение данных — это одно, обработка (например удаление) шума — другое, анализ (статистический/Фурье/etc) третье.

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

                      Во-вторых, АЦП работает не всегда
                      Но ведь можно же просто не писать данные, когда их нет. А когда появляются, продолжать писать в другом файлике.

                      Я твердо придерживаюсь той точки зрения, что подобные «аномалии» надо заменять Nan-ами, а не какими-то искусственными (вымышленными) значениями.
                      А можно их вообще не заменять. Шум, выходы за пределы и прочее — это тоже информация, которую можно измерять и анализировать.

                      Да, это возможный вариант… только вот в году 3*Е+7 сек, или 3Е+10мс.
                      Для этого давно придумали структуру под названием «chunk», когда данные хранятся не одним непрерывным куском, а разбиваются на одинаковые небольших размеров. Перемотка в видео- и аудио-файлах достижима именно таким подходом.

                      А затем обрабатывать с учетом этого факта.
                      По вашей ссылке чужих не пускают.


      1. xortator Автор
        16.05.2023 07:57
        +2

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


        1. adeshere
          16.05.2023 07:57

          Похожие симптомы могут быть при наличии неопределённого поведения (UB) в программе.

          Я немного читал про UB в программах на Си, но в фортране такого вроде бы быть не должно? Кроме того, что язык к этому подталкивает (у меня включен максимальный уровень диагностики, включена проверка выхода индекса за пределы массива, и т.д.), я еще и сам в принципе не использую никаких трюков, которые могут хоть как-то к UB приводить - стараюсь чтобы все было однозначно. Например, не использую указатели, избавляюсь от устаревших конструкций вроде оператора common, счетчики циклов у меня 64-битные (чтобы переполнения не было), для real-операций в подозрительных местах стоят проверки диапазона данных (хотя по идее там по умолчанию должно получаться не UB, а inf, которое я сразу же конвертирую в Nun) и т.д. Тем более, в тестовой проге...

          К сожалению, про UB в Си на Хабре пишут достаточно регулярно, а вот про UB в фортране я вообще ни одной статьи не нашел :-(


          1. Refridgerator
            16.05.2023 07:57

            Потому что в настоящее время на Фортране пишут два с половиной человека. Формат float сам по себе практически UB и от языка программирования не зависит, равно как и другие виды UB.


            ООП же совсем не случайно придумали. Как, например, в процедурном стиле защититься от складывания метров и километров, если и там и там тип float? Правильно — никак. А в ООП можно отдельно задать типы Meter и KiloMeter и их нельзя будет сложить до тех пор, пока явно не перегрузить операции сложения и приведения к одному типу.


            1. adeshere
              16.05.2023 07:57

              Формат float сам по себе практически UB и от языка программирования не зависит, равно как и другие виды UB.

              Справедливости ради, в фортране есть довольно много встроенных функций, которые предназначены как раз для контроля точности вычислений и предотвращения float UB. Конечно, это не панацея, но если я правильно понимаю, в других языках их аналоги появились заметно позже. А чтобы выйти за границы массива, надо специально постараться, так как, к примеру, при его передаче в функцию в качестве параметра туда одновременно передается размер и форма. Включая и динамические массивы. Аналогично со строками. Ну и не надо использовать указатели там, где они не нужны (а в фортране они почти нигде не нужны). Кстати, чтобы попасть на UB в фортране, мало все это проигнорировать - надо еще и выключить соответствующие проверки в компиляторе. Которые, если я правильно понимаю, реализованы достаточно эффективно - так как их выключение в типичных случаях на производительность почти не влияет (а значит и незачем выключать).

              Конечно, выстрелить себе в ногу можно на любом языке. Но сделать это неумышленно в фортране достаточно сложно. Если, конечно, не писать в стиле F-77, со всеми этими COMMОN, EQUIVALENCE, ENTRY и прочими GOTO.

              Потому что в настоящее время на Фортране пишут два с половиной человека. 

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

              ООП же совсем не случайно придумали

              Кстати, да! Очень уместное замечание, так как начиная с фортрана 90, а тем более - с фортрана-2003, основные элементы ООП вполне себе поддерживаются языком. Конечно, я лишь очень поверхностно наслышан про ООП, так как почти не знаю других языков, кроме фортрана. Но если судить про ООП по всяческим википедиям, то в фортране практически все это есть, причем пользоваться этим легко и просто. Производные типы, объединение данных и работающих с ними методов в модулях (фактически это классы), публичные интерфейсы и приватное внутреннее устройство, конструкторы (чуть хуже с деструкторами), наследование, перегрузка функций и операторов - все это есть из коробки "на блюдечке". Даже такие динозавры, как я, все вышеперечисленное (кроме деструкторов) используют постоянно. А конструкции на основе FORALL и WHERE позволяют на 90% избавиться от явно прописанных циклов при обработке многомерных массивов, причем без потери эффективности (наоборот, обещается, что компилятор якобы сам все это распараллелит - и есть подозрения, что это у него даже иногда получается ;-). В сумме все это дает возможность непрофессионалам писать достаточно эффективные программы для научных расчетов. Что при использовании, например, С++ было бы невозможно (там для этого требуется совершенно другой уровень программистской квалификации).


            1. xortator Автор
              16.05.2023 07:57
              +1

              Чаще всего floating point реализован по IEEE 754 и не зависит от языка. Но к такому может приводить undefined behavior вообще в другой части кода, а не в какой-нибудь FP-инструкции. Я на фортране не пишу, но гугл говорит, что UB в нём есть.


    1. Refridgerator
      16.05.2023 07:57
      +2

      Нормально вы так заморочились) А причина бага понятна, если посмотреть на ассемблерный код — это использование FPU несмотря на то, что явно указано использовать SSE. Это может быть только по двум причинам:
      1) в библиотеке рандома явно указан расширенный тип с плавающей точкой (80-битный) и у компилятора нет другого выбора, или
      2) разработчики компилятора слегка схалтурили.


      В чём проблема использования FPU:
      1) он имеет стековую организацию регистров,
      2) на регистры с плавающей точкой маппятся регистры MMX и их нельзя использовать одновременно,
      3) он спроектирован для однопоточного использования и имеет глобальные статусы, определяющие точность вычисления, сторону округления и прочее.


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


      Правильное решение — это:
      1) Отключите /Qparallel. Если вам нужна многопоточность — её лучше делать вручную точно зная, какие ресурсы разделяемы, какие нет, и как их правильно синхронизировать;
      2) напишите свой генератор случайных чисел;
      3) перейдите на другой, более современный язык программирования. Чем больше популярен язык, тем меньше багов у компилятора.


      1. adeshere
        16.05.2023 07:57

        Правильное решение — это:

        1) Отключите /Qparallel. Если вам нужна многопоточность — её лучше делать вручную точно зная, какие ресурсы разделяемы, какие нет, и как их правильно синхронизировать;

        Пробовал. Скорость резко падает практически на всех машинах, где удалось провести тесты, а баг не исчезает (проверено на одной из машин) :-((

        2) напишите свой генератор случайных чисел;

        Уже давно написал обертку к нему (просто вызываю ГСЧ повторно, если он вернул Nun). Но к сожалению, баг появляется не только при вызове ГСЧ. Просто там я его впервые заметил, т.к. ГСЧ вообще не должен Nan продуцировать. У меня ведь Nun - это вполне штатное значение данных. В экспериментальных рядах мониторинга они сплошь и рядом встречаются, особенно после чистки аппаратурного брака. Поэтому эти спонтанные "десятимиллионники" не так просто заметить, если специально не заморачиваться. Но главное - они появляются в непредсказуемом месте, в том числе и при вызове библиотечных функций. А к каждой функции обертку не сделаешь...

        3) перейдите на другой, более современный язык программирования. Чем больше популярен язык, тем меньше багов у компилятора.

        Хороший совет, только вот нереалистичный. У меня все задачи расчетные, там фортрану нет равных по простоте языка, при том, что по производительности он в топе. Одни массивные операторы и сечения чего стоят. Но главное, у меня несколько сотен тысяч строк кода собственного legacy, и весь этот код постоянно работает и используется. Новые методы просто встраиваются в этот пакет. Для многих коллег это основной рабочий инструмент, хотя и весьма узконишевый. Переписывать его на другом языке - задача неподъемная и бессмысленная. Да и где гарантия, что новые компиляторы будут менее подвержены багам? Скорее наоборот - чем более бурно язык развивается, тем больше шансов, что там где-то есть глюки. Я за разными языками не особо слежу, но вот только недавно здесь на Хабре ругали один из современных языков за многочисленные ошибки, которые годами не устраняются. Или можно вспомнить про другой очень популярный современный язык, стандарт которого при выпуске очередной версии поменялся настолько, что старые программы проще запускать в среде 15-летней давности, чем переписать под новый стандарт (впрочем, этот язык для моих задач по-любому не актуален).

        Впрочем, Вы правы, что мне стоило бы на наиболее свежую версию компилятора перейти. Возможно, там этот баг поправлен. Но тут уже не все от меня зависит...


        1. Refridgerator
          16.05.2023 07:57

          Если в программе в произвольный момент времени и в произвольном месте может появиться ничем не обусловленный NaN — это не баг, а эпик фейл. Я бы не доверился ни такой программе, ни компилятору (если проблема действительно в нём, а не в программе), ни программисту, который решает такие проблемы костылями типа while(isNaN(x)) x=random(); (ничего личного). Если компилятор приобретён легально — то обращаться нужно к самим Intel. Если нет — обращаться надо к рутрекеру за другими, не обязательно более новыми версиями.


          1. adeshere
            16.05.2023 07:57

            Я бы не доверился ни такой программе, ни компилятору ... ни программисту

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

            Кроме того, у меня на каждый такой эпик-фейл-Nan есть примерно миллион "своих" Nan-ов. Которые всегда обрабатываются, так что на результат вычислений их наличие почти никак не влияет (конечно, пока "хороших значений заметно больше, чем Nan-ов - но это условие, к счастью, почти всегда выполнено). Фактически из-за этого "эпик-фейла" я просто теряю еще одно значение данных из 10 миллионов в дополнение к миллиону уже потерянных раньше.

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

            Вообще, принцип робастности настоятельно требует, чтобы при потере 0.0001% значений данных результат совсем не менялся. Если вдруг это не так, то для анализа экспериментальных сигналов такой алгоритм (который не слишком устойчив к малому изменению входных данных) категорически непригоден. Мы ведь обычно ищем устойчивые закономерности на фоне шумов, а они не должны опираться на единичные артефакты...

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

            Если компилятор приобретён легально — то обращаться нужно к самим Intel. Если нет — обращаться надо к рутрекеру за другими, не обязательно более новыми версиями.

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


            1. Refridgerator
              16.05.2023 07:57

              Если честно, то тут у меня тоже все в «серой» зоне. Компилятор был куплен легально, но очень давно, на организацию, но после истечения срока лицензии она официально не продлевалась.
              Я ещё обратил внимание, что код 32-битный, заметно старый компилятор. Проблемы могут быть ещё и от того, что он не учитывает особенности современных процессоров с технологиями типа Hyper-Threading.

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


        1. Refridgerator
          16.05.2023 07:57

          Отключите /Qparallel. Пробовал.
          Из этого, кстати, программа вовсе не обязана стать однопоточной, особенно если остаётся возможность распараллеливать задачи вручную. Реальное количество потоков в программе можно увидеть даже через стандартный диспетчер задач.


  1. Panzerschrek
    16.05.2023 07:57

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


    1. xortator Автор
      16.05.2023 07:57

      Более того, они ещё и UB производят иногда. :) Надеюсь и до этого когда-нибудь добраться.