Как стать автором
Обновить

Комментарии 87

Репутация Intel Fortran как "быстрого компилятора" создавалась еще тогда, когда gfortran как такового не было, был g77 в составе GCC 2.95, который оптимизировал действительно очень так себе. Я в те времена экспериментировал с будущим gfortran в альфа-версии (кажется, он тогда назывался gfc) и сборками ATLAS, и Intel тогда действительно заметно опережал g77 на настройках по умолчанию, но выкручиванием оптимизации можно было к нему приблизиться. Насколько я знаю, у нас я был единственным, кто вообще заморачивался этим вопросом, все остальные просто слепо верили, что Intel быстрее, но не могли сказать, насколько. Подозреваю, с тех времен так же слепо и верят.

Еще те кто пишут на фортране свято верят, что по скорости они уделывают C/С++, как бог черепаху, и что вся настоящая быстрая математика написана на фортране. Хотя к примеру в тех же численных библиотеках питона, а конкретнее реализации BLAS(OpenBLAS/MKL/ATLAS), его уже давно закопали.

Если посмотреть OpenBLAS на гитхабе - то там около 30% кода на фортране, а С около 50 - пока не сильно похоже что его давно закопали (https://github.com/xianyi/OpenBLAS)

Насколько я знаю, это в основе scipy лежат биндинги к фортрановскому коду, и никто там пока ничего менять не планирует.

Такое тоже, хотя тут важнее другой фактор. Фортран - единственный язык, для которого стандарт оговаривает нюансы поведения floating-point. Компилятор C обходится с оптимизацией намного более вольно. Например, C как правило считает, что x + a - x == a, а фортран учитывает пограничный случай с ошибкой округления. Или, например, gcc самовольно заменяет float на double, а фортран, если ему велели работать в одинарной точности, в одинарной и посчитает. В некоторых численных алгоритмах это важно, алгоритм Кэхэна - наглядный пример.

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

Большинство все таки пишет численно стабильные алгоритмы. А так насколько я понимаю в C достаточно скобок набросать, чтобы был нужный порядок.

Скобки в Си не задают физический порядок вычислений. Они просто определяют логическую интерпретацию выражения.

По вашей же ссылке написано, что порядок вычислений не определён. Это дерево можно обходить в любом порядке. Когда вы пишете, например, a*b, то никакими скобками вы не добьётесь гарантии, чтобы a вычислялось вперёд b.

Какая разница в каком порядке обходить дерево, если для подвыражений выполняется отношение "получен результат до". Или вы думаете что обход дерева: ((a1*b1) + (a2*b2)) + (a3*b3))

в порядке:

r1=a1*b1, r2=a2*b2, r3=a3*b3, r4=r1+r2, r5=r4+r3, output r5

и

r3=a3*b3, r2=a2*b2, r1=a1*b1, r4=r1+r2, r5=r4+r3, output r5

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

Во-первых, у вычислений может быть побочный эффект различной степени глубины или асинхронное действие (то, что в Фортране задаётся описателями impure, pure, simple).

Во-вторых, даже расстановка скобок a*(b*c) в Си не гарантирует, что b*c будет выполнено вперёд a*b.

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

Расстановка скобок однозначно задает и гарантирует, об этом было собственно написано в этой ссылке https://stackoverflow.com/a/52788550 с приведением пункта стандарта. И это было проверено мною ни однократно в "живой природе", ни одного отклонения от этого поведения зафиксировано не было.

PS: если не верите, можете сходить на какой ни буть godbolt, врубить максимальную оптимизацию -O3, и увидеть как меняется порядок вычислений в зависимости от скобок.

Да что вы такое говорите?

$ cat eval.c
#include <stdio.h>

int a () {
  puts ("a");
  return 1;
}

int b () {
  puts ("b");
  return 2;
}

int c () {
  puts ("c");
  return 3;
}

int main () {
  int s;
  s = a()*(b()*c());
  printf ("%d\n", s);
  return 0;
}

$ gcc eval.c
$ ./a.out
a
b
c
6

Компилятору плевать на скобки на любом уровне оптимизации.

О господи, ну нельзя так.

вот выхлоп:

       call    a()
        mov     ebx, eax
        call    b()
        mov     r12d, eax
        call    c()
        imul    eax, r12d
        imul    eax, ebx
        mov     DWORD PTR [rbp-20], eax

Объясняю, что там происходит:

1) вызов a()

2) вызов b()

3) вызов c()

4) умножение результата b() на результат c()

5) домножение на результат a()

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

Ну а я о чём говорю? Что скобки не задают порядок вычислений.

Если в каком-нибудь Лиспе напишете

(* a (* b c))

то гарантируется, что сначала вычислится a, потом b, потом c, потом правое умножение, потом левое умножение, потому что аргументы функции вычисляются слева направо. А в Си такого нет.

Вот конкретно про С++ расписано.

Скобки задают отношение "вычислено до" на "дереве" вычисления, о чем я вам практически в самом начале и написал. И в вашем примере происходит вычисление result_a*(result_b*result_c), именно в соответствии с этим отношением, как и задает выражение скобок. Где здесь противоречие?

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

В моём примере result_a вычисляется до result_b и result_c. Вот то, о чём я пишу. И это важно, когда они являются неатомарными значениями и могут влиять друг на друга. Тем более когда мы обсуждаем параллельное программирование.

А про арифметические операции писал@agalakhov. Я не настолько силён в вещественной арифметике, чтобы в этом вопросе иметь какую-то позицию.

Сначала вы утверждали, что в Си нельзя добиться порядка арифметических операций в a*(b*c), и что скобки это логическая конструкция.

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

Да функции здесь вообще не причём.

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

t = *timer - *timer;

присвоит переменной t положительное значение, отрицательное значение или 0?

Ну, допустим, не 0, если timer будет описано, как volatile. Но со знаком (который зависит от порядка вычислений) нет никакой возможности определиться.

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

Это очень странное свойство для языка низкоуровневого программирования, в качестве которого используется Си. Скорее, этого можно было бы ожидать именно от Фортрана, как языка сильно оптимизируемых компилятором вычислений. Оно не объяснимо ничем, кроме пофигизма K&R, которые вовремя не подумали прописать это в стандарте.

timer не может измениться. Если это memory mapped i/o, в каком ни буть микроконтроллере(или ядре и драйвере), или "параллельный" код, то это просто совершенно по другому пишется и реализуется. Фортрана на микроконтроллерах, я к примеру, вообще ни разу не видел, там сплошной Си. (хотя возможно он там и есть)

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

Напомню, что вы комментируете статью как раз о параллельном коде.

Кстати, с этим связан ещё вопрос взаимовлияния правой и левой частей оператора присваивания, с которым в Си тоже не всё хорошо (например, когда мы присваиваем перекрывающиеся части массива).

Вы просто не очень понимаете, о чем идет речь. На Си и плюсах написаны мегатонны параллельного кода. Только он пишется ни так как вам кажется с вашим исключительным опытом в Фортране.

Почему вы думаете, что у меня исключительный опыт в Фортране? Я владею несколькими десятками языков программирования и имею 25-летний опыт программирования на Си. У меня есть патент РФ на конструкцию транспьютера, программируемого на Си. Я разрабатывал компилятор диалекта Си. И я уверен, что параллельному программированию на Си я тоже мог бы поучить многих.

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

Тогда бы вы не написали предыдущий пост про *timer (1) - *timer (2), предполагая что выборка из памяти 1 произойдет раньше чем 2, если вы напишите это на Фортране. Даже если написать на ассемблере с явным указанием порядка операций на современных out-of-order процессорах это не гарантируется. Нужны барьеры или другие средства сериализации.

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

предполагая что выборка из памяти 1 произойдет раньше чем 2, если вы напишите это на Фортране.

Вас не затруднило бы процитировать, где я такое предполагал?

Выше я приводил в этом контексте пример на Лиспе, и в Лиспе порядок вычислений однозначно определён.

Никакое внеочередное исполнение (out of order) в процессоре не вызовет перестановку выборок из памяти одним ядром по одному и тому же адресу (в нашем случае *timer) в обратном порядке, просто потому что у одинаковых операций нет причин переупорядочиться в очереди. Ну и естественно, что на отображаемые в память регистры в микроконтроллерах вообще не распространяется оптимизация доступа к памяти.

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

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

https://en.wikipedia.org/wiki/Memory_ordering

"On most modern uniprocessors memory operations are not executed in the order specified by the program code."

Выборки из памяти в машинном языке могут быть неупорядоченными, а дело транслятора – обеспечить конвенции, принятые в языке высокого уровня, относительно объектов этого языка.

Если речь о Фортране, то задача его компилятора – автоматически расставить всякие там барьеры или точки синхронизации, где они нужны. А если речь о Лиспе, то программы на нём не работают непосредственно с оперативной памятью, они обращаются к атомам, и эти обращения должным образом упорядочиваются средой выполнения. Семантика интерпретации S-выражений в Лиспе гарантирует, что их элементы вычисляются слева-направо, и никак иначе. Вычисление второго операнда просто не может начаться, пока не закончилось вычисление первого (за исключением специальных параллельных форм).

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

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

Ещё раз обращаю внимание, у вас некая путаница в голове. Я говорю о порядке выполнения операций в языке программирования. Причём тут обращения к памяти или многопоточка? Оператор языка высокого уровня не равен какой-то определённой последовательности машинных команд, даже в Си. Программа и её машинный код имеют сопоставимую семантику только взятые целиком.

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

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

Вы говорили изначально про многопоточку приведя в пример *timer - *timer, утверждая что Си плохой. Мое утверждение, что многопоточка везде пишется особым образом. И в данном случаи, если просто написать такой код, хоть на Фортране, хоть на Лиспе, хоть на Си. Результат будет одинаковый, а именно выборки из памяти могут произойти в произвольном порядке.

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

Автоматически распараллеливать код и компиляторы Си умеют, через OpenMP.

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

Вы говорили изначально про многопоточку приведя в пример *timer - *timer, утверждая что Си плохой. 

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

Автоматически распараллеливать код и компиляторы Си умеют, через OpenMP.

OpenMP само по себе – это ручное распараллеливание.

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

Я вас прошу, дайте себе, пожалуйста, труд внимательно прочитать комментируемую вами статью. А желательно сами скачайте компилятор Intel Classic Fortran и повторите трансляцию приведённых в ней примеров.

приведя более простой пример просто с обращением к асинхронно изменяющейся переменной в памяти.

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

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

Вы издеваетесь? Прочитайте еще раз ссылку "memory ordering". Очевидно требует, если вы хотите чтобы в коде: *timer (1) - *timer (2), 1 прочиталось из памяти раньше 2.

Проблема в том, что в современных out-of-order процессорах без примитивов синхронизации этот порядок не определен. Хоть обкодогенерируйся.

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

Язык Си: Order of evaluation of the operands of any C operator, including the order of evaluation of function arguments in a function-call expression, and the order of evaluation of the subexpressions within any expression is unspecified...

Язык Фортран: An expression can contain more than one kind of operator. When it does, the expression is evaluated from left to right, according to the following precedence among operators...

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

Логика работы процессора, в том числе и при внеочередном исполнении операций, вполне детерминирована.

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

Вы вообще с чем спорите?

1) Современные процессоры пере упорядочивают чтения из памяти

2) Код без процессорных примитивов синхронизации, неизбежно столкнется с 1)

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

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

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

Вы издеваетесь или не понимаете, о чём я пишу? Я прекрасно знаю, что такое Load-Load reordering. Вот только он относится к порядку загрузки переменных a и b, а вы спорите о случае одинаковых инструкций Load a - Load a.

Причём в вашем источнике очень подробно и конкретно расписано, каким отличием между a и b вызвана эта перестановка.

Load a - Load a, является частным случаем Load a - Load b. И соответственно будет давать такой же результат. Или вы думаете что если адрес одинаковый, то и переупорядочивание будет магическим образом отключаться? Ладно, я понял, что вы просто хотите оказаться правым в конце. Поэтому не вижу смысла продолжать диалог. Удачи.

Если адрес одинаковый, то условие либо в первом, либо во втором пункте описанного в вашем источнике алгоритма LoadLoad не будет выполняться, поэтому дальше по этому алгоритму процессор не пойдёт. Тут нет никакой магии. Алгоритм работает только тогда, когда a чем-то лучше для процессора, чем b.

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

Алгоритм работает только тогда, когда a чем-то лучше для процессора, чем b.

Ну банально кинули обе микрооперациии загрузки одновременно на два разных load/store порта, но в первом потом пошли столлы из за кеш миссов.

Микрооперации АЛУ находятся выше логики работы с памятью. Первое значение даже в таком сценарии (не берусь утверждать, насколько он возможен) всё равно будет выбрано из памяти раньше, просто его путь на первом этапе будет дольше. Ну и к страницам отображаемой на устройства в/в памяти кеширование вообще не применяется путём установки соответствующего режима PAT и/или MTRR.

Если инструкции декодированы и переданы в back end на одном такте, то дальше определить, какая из них первая, как вторая, можно только при явных ограничениях на порядок. В слабой модели памяти ограничений нет - кто проползёт быстрее по пайплайну, тот и пойдёт первым к контроллеру памяти (ну или какому то другому в случае MMIO), хочется порядка - расставляйте fence явно.

В слабой будут расставляться fence в результате трансляции volatile (хотя в стандарте Си про это опять-таки ничего нет). Последней такой архитектурой, вроде, была DEC Alpha.

будут расставляться fence в результате трансляции volatile

Попробовал, ничего не ставится.

Последней такой архитектурой, вроде, была DEC Alpha.

Архитектура ARM прошла мимо вас?

ARM консистентен и последователен в пределах локального кеша. Там артефакты возникают из-за несинхронности обмена данными между памятью разных ядер. ARM гарантирует последовательную консистентность при условии отсутствия гонок между нитями (data-race-free). Внутри одной нити он гарантированно логически не переупорядочивает операции.

В документации не подскажу, но статья по этому поводу вот (ключевое слово DRF-SC).

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

По вашей ссылке очень общими словами обрисовывается предмет, там не вдаются в детали.

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

ключевое слово DRF-SC

Ну да, всё правильно написано - "This model assumes that hardware has memory synchronization operations separate from ordinary memory reads and writes. Ordinary memory reads and writes may be reordered between synchronization operations, but they may not be moved across them." Нужен порядок - используйте synchronization operations.

Ещё раз. Все современные (со второй половины 1990-х годов) архитектуры процессоров гарантируют условие DRF-SC (оно же SC-DRF, оно же DRF1), которое заключается в том, что, если логически при любом взаимном позиционировании последовательностей команд в разных нитках получается корректный результат (data-race-free), то в каждой отдельной нитке программа ведёт себя в точности как чисто последовательная (sequential consistent).

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

Ну так переупорядочивание двух соседних чтений sequential consistency никак не нарушает.

Как не нарушает, если это две команды из двух разных точек программы? Которые исполняются в различных контекстах.

Вот что пишут, например, в описании языка Java, где на уровне языка гарантируется DRF-SC:

A program is correctly synchronized if and only if all sequentially consistent executions are free of data races.

If a program is correctly synchronized, then all executions of the program will appear to be sequentially consistent (§17.4.3).

This is an extremely strong guarantee for programmers. Programmers do not need to reason about reorderings to determine that their code contains data races. Therefore they do not need to reason about reorderings when determining whether their code is correctly synchronized. Once the determination that the code is correctly synchronized is made, the programmer does not need to worry that reorderings will affect his or her code.

В языках Си и C++ не гарантируется DRF-SC, что и понятно, поскольку уже на уровне кодогенерации компилятор Си не гарантирует консистентного поведения.

если это две команды из двух разных точек программы? Которые исполняются в различных контекстах.

Мы тут обсуждаем два чтения внутри одного арифметического выражения без сайд эффектов (t = *timer - *timer; ). В общем случае, когда между чтениями есть ещё какой то код, переставлять конечно можно не всегда.

Правильно. Я очень рад, что вы вернулись к обсуждаемому контексту.

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

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

Но проблема в том, что даже если компилятор сгенерирует инструкции чтения в порядке, соответствующем коду программы, реальный порядок выполнения на процессоре может меняться в зависимости от состояния пайплайна - так что от гарантий языка толку мало. Если в приведённом примере *timer может меняться кем то ещё (неважно, другим потоком или внешним устройством) - то мы имеем data race, исправлять который надо соответствующими средствами языка и процессора.

Само по себе последовательное циклическое чтение атомарной переменной, изменяемой в другом процессе – это не data race, это и есть единственный возможный в языке Си штатный механизм взаимодействия между процессами с общей памятью. Если с другой стороны посмотреть, то это и есть механизм синхронизации на логическом уровне.

Чтобы, как я надеюсь, окончательно закрыть тему, сошлюсь вот на эту работу, где авторы подробно разобрали все артефакты обращения к памяти на ARM и RISC. Наш случай рассматривается в разделе 10.4 на страницах 34-35. Там показано, что последовательные операции чтения из одного адреса могут быть переставлены описанным вами образом только в том случае, если они фактически дают одинаковый результат. Если при втором чтении (которое по тексту программы было первым) оказывается, что его результат не совпал с первым чтением (а точнее говоря, даже если он просто был сформирован другой командой записи в память, чем первое чтение), то результат первого чтения отменяется и оно выполняется заново, в третий раз, чтобы фактически следовать за более ранним по тексту программы.

Спасибо, вот это уже интересно, надо почитать повнимательнее.

А вот что пишет по этому поводу моё любимое IBMовское руководство по Fortran XL:

The VOLATILE attribute is used to designate a data object as being mapped to memory that can be accessed by independent input/output processes and independent, asynchronously interrupting processes. Code that manipulates volatile data objects is not optimized.

[...]

Any data object that is shared across threads and is stored and read by multiple threads must be declared as VOLATILE. If, however, your program only uses the automatic or directive-based parallelization facilities of the compiler, variables that have the SHARED attribute need not be declared VOLATILE.

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

И нет, скобок в Си формально недостаточно. Даже разбиения выражения на несколько строк с промежуточными переменными недостаточно. Оптимизатор компилятора имеет полное право код вида

double t = x + a;
double y = t - x;

превратить в double y = a;, и это не будет считаться багом. Несколько версий назад gcc именно так и поступал, сейчас "поумнел", но формально по стандарту он не обязан считать это правильно.

У меня есть знакомые, которые профессионально пишут вычислительные алгоритмы на си/плюсах. Да иногда порядок важен и другого выхода нет. Обычно это решается, скобочками или разбиением выражения на утверждения(разделенные ";") однозначно задающими порядок.

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

PS: обычно это настраивается флажками оптимизации, отключающими агрессивную оптимизацию на плавучке. Да, мог выкинуть вычисления, не имеющие с точки зрения компилятора побочного эффекта.

Ну тут не столько порядок вычисления, сколько допустимость тех или иных алгебраических преобразований. Например, замена a-b = -(b-a) все еще валидна, как и замена a - a = 0, но вот замена a + b - a - b = 0 уже недопустима. Сейчас с ростом популярности NumPy и нейросетей авторы компиляторов Си стали вычислять эти выражения "как в фортране", и это хорошо. Но это в компиляторе, а в стандарте так и стоит "undefined", и, если по вине этого "undefined" спутник на орбиту не выйдет, то виноваты будут точно не авторы компилятора.

А если сравнить "классический" ifort (сейчас он называется "Intel Fortran Compiler Classic") с новым ifx (теперь именно он называется "Intel Fortran Compiler")?

Может быть, я до такого сравнения и дозрею. Но, судя по тому, что в fix даже чисто синтаксически стандарт языка реализовали только несколько месяцев назад, вряд ли там какая-то особо зрелая технология. При этом под macOS его нет, а под Linux, боюсь, как и все нештатные расширения llvm, он будет вставать с большим скрипом.

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

Проверил на ноутбуке i5-5200U@2.20GHz (приведен лучший из трех последовательных запусков):


ifort life_seq:  26 сек,  52470000 ячеек/с
ifx   life_seq:  25 сек,  54270000 ячеек/с
ifort life_mat:  43 сек,  32360000 ячеек/с
ifx   life_mat:  40 сек,  34450000 ячеек/с
ifort life_omp:  13 сек, 100740000 ячеек/с
ifx   life_omp:  17 сек,  80460000 ячеек/с
ifort life_con:  13 сек, 100810000 ячеек/с
ifx   life_con:  17 сек,  80790000 ячеек/с
ifort life_con2: 15 сек,  91720000 ячеек/с
ifx   life_con2: 51 сек,  27180000 ячеек/с

Вывод: ifort гораздо лучше справляется с распараллеливанием, но ifx может давать лучший результат для последовательного кода (но скорее всего это в пределах погрешности).

Очень интересные результаты, особенно последний. Можно предположить, что проседание в объединённом параллельном цикле может быть связано с неоптимальным выбором порядка доступа к памяти.

Это у вас Windows или Linux?

Windows (кстати, если кто будет повторять: размер стека задается при компиляции ключом /F).

Помнится, когда я последний раз плотно работал с Фортраном (давно уже, лет 15 назад), там зарождалась фича coarray для абстракции MPI/OpenMP. Сейчас уже мне не нужно, и времени вникать в это нет, но было бы интересно почитать, чем дело кончилось.

Об этом в следующей серии.

$ gfortran life_seq.f90 -o life_seq_g -O3 -ftree-vectorize -fopt-info-vec -flto

$ ifort life_seq.f90 -o life_seq -O3

Для честности надо было гну компилятору тоже только -O3 оставить. Или заняться подбором ключей для интеловского - отдельное увлекательное занятие )

Интеловский компилятор включает векторные оптимизации по умолчанию в режиме -O3.

Кроме векторизации там ещё много всего есть (сходу не скажу, например, включает ли -O3 IPO/LTO, можно с -ip/ipo поиграться). И в любом случае в последнее время рекомендуется векторизовать явно через omp simd. Сталкивался с тем, что с новой версией icc автовекторизация слетала, думаю в фортране то же самое. Ну и -march всё таки стоит обоим компиляторам сказать, а то сгенерят какой нибудь древний SSE.

PS В современном gcc "Vectorization is enabled at -O2 which is now equivalent to the original -O2 -ftree-vectorize -fvect-cost-model=very-cheap" https://gcc.gnu.org/gcc-12/changes.html

В целом, у компилятора Intel очень разумно выбраны умолчания режимов оптимизации кода.

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

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

-mtune влияет только на скедулинг кода, но не даёт использовать инструкции, которых может не быть на другом железе (скажем, AVX или AVX512).

Через omp simd можно векторизовать (особенно если хочется использовать GPU

OMP Offload явно отдельная тема, там кроме векторизации много нюансов ) Но и на CPU выигрыш от omp simd может быть заметный - хотя соглашусь, что это тоже тема для отдельной статьи.

так как мы занимаемся параллелизацией.

А про выставление affinity в следующей серии расскажете? Оно, конечно, на многосокетной системе важнее, но и на клиентской машинке перекидывание потока с ядра на ядро не так безобидно.

В следующей серии будет про MPP и комассивы.

Вот за ifort было обидно:
ifort life_seq.f90 /O3 /Qparallel
дает ускорение в 6 раз.

Справедливое замечание, хотя на 4-ядерном процессоре, конечно, не в 6 раз. Ключ parallel даёт возможность автоматически частично параллелизовать последовательный код, хотя и не так эффективно, как явное указание параллелизма. Отражу это в статье, спасибо! Сравним автоматические параллелизаторы.

Да, это на моем 6-ядерном райзене. Я уточнил цифры:
Ryzen 5 ( 6 ядер) - 5,61
i9 (8 ядер) - 5,91

как видно ускорение замедляется и узким местом становиться память.

На старом 8-ядерном  Xeon 4C E3-1270v2  из второй части статьи – ускорение вообще всего в 2 раза.

в 2 раза это странно. возможно что-то пошло не так.

добавлю Xeonoв:
E-2224 (4 ядра) 3,39
Gold 6140 (18 ядер) 8 раз.
но лучшее время все равно у i9 - 1.9 сек,
6140-й дремал на 1Гц ))

Отличная статья, спасибо!

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

Вы смотрите прямо в корень. Похожая вещь сделана во второй части статьи, и это действительно даёт прирост производительности.

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

Когда-то я ударился в эксперименты и решил написать генератор кода, который на входе получал бы уравнения в матричном виде и на выходе давал бы распаралеленную программу. В итоге идея не взлетела (по причине моего слабого понимания некоторых вещей на тот момент), но позволила мне дешево поэкспериментировать с разными методами распаралеливания, так как оно все равно делалось автоматически. Так я с удивлением узнал, что spatial decomposition + MPI дает бОльшую производительность, чем чистый OpenMP на одном процессоре, несмотря на обмены MPI, за счет эффекта маленькой сетки. В итоге я пришел к тому, что нужно брать одномерные столбцы/строки из трехмерной сетки, объединять их в пучки подходящего размера и отправлять на счет, часть в CPU, часть в GPU, чтобы загрузить вообще всё. Но я не смог тогда справиться с синхронизацией и в результате половина мощностей всегда простаивала. А в те редкие моменты, когда она не простаивала, узел кластера зависал из-за перегрева или из-за какой-то другой причины, связанной с перегрузом :)

Идеально синхронизировать вообще невероятно сложно.

Мы занимаемся ГД/МГД-моделированием на сетках 

Это что-то вроде уравнения Навье-Стокса?

Ну примерно, но в астрофизических применениях. Вязкость у нас своеобразная, не такая как в уравнениях Навье-Стокса.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории