Вашему вниманию предлагается небольшой обзор возможностей векторизации алгоритмов в .NET Framework и .NETCORE. Цель статьи познакомить с этими приёмами тех, кто их вообще не знал и показать, что .NET не сильно отстаёт от "настоящих, компилируемых" языков для нативной
разработки.
Я только начинаю изучать приёмы векторизации, так что если кто из сообщества укажет мне на явные косяк, или предложит улучшенные версии описанных ниже алгоритмов, то буду дико рад.
Немного истории
В .NET SIMD впервые появился в 2015 году с выходом .NET Framework 4.6. Тогда были добавлены типы Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 и Vector4, которые позволили прозводить векторизированные вычисления. Позже был добавлен тип Vector<T>, который предоставил больше возможностей для векторизации алгоритмов. Но многие программисты всё равно были недовольны, т.к. вышеописанные типы ограничивали поток мыслей программиста и не давали возможности использовать полную мощь SIMD-инструкций современных процессоров. Уже в наше время, в .NET Core 3.0 Preview появилось пространство имён System.Runtime.Intrinsics, которое предоставляет гораздо большую свободу в выборе инструкций. Чтобы получить наилучшие результаты в скорости надо использовать RyuJit и нужно либо собирать под x64, либо отключить Prefer 32-bit и собирать под AnyCPU. Все бенчмарки я запускал на компьютере с процессором Intel Core i7-6700 CPU 3.40GHz (Skylake).
Суммируем элементы массива
Я решил начать с классической задачи, которую часто пишут первой, когда речь заходит про векторизацию. Это задача нахождения суммы элементов массива. Напишем четыре реализации этой задачи, будем суммировать элементы массива Array:
Самая очевидная
public int Naive() {
int result = 0;
foreach (int i in Array) {
result += i;
}
return result;
}
С использованием LINQ
public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i);
С использованием векторов из System.Numerics:
public int Vectors() {
int vectorSize = Vector<int>.Count;
var accVector = Vector<int>.Zero;
int i;
var array = Array;
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = new Vector<int>(array, i);
accVector = Vector.Add(accVector, v);
}
int result = Vector.Dot(accVector, Vector<int>.One);
for (; i < array.Length; i++) {
result += array[i];
}
return result;
}
С использованием кода из пространства System.Runtime.Intrinsics:
public unsafe int Intrinsics() {
int vectorSize = 256 / 8 / 4;
var accVector = Vector256<int>.Zero;
int i;
var array = Array;
fixed (int* ptr = array) {
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = Avx2.LoadVector256(ptr + i);
accVector = Avx2.Add(accVector, v);
}
}
int result = 0;
var temp = stackalloc int[vectorSize];
Avx2.Store(temp, accVector);
for (int j = 0; j < vectorSize; j++) {
result += temp[j];
}
for (; i < array.Length; i++) {
result += array[i];
}
return result;
}
Я запустил бенчмарк на эти 4 метода у себя на компьютере и получил такой результат:
Method | ItemsCount | Median |
---|---|---|
Naive | 10 | 75.12 ns |
LINQ | 10 | 1 186.85 ns |
Vectors | 10 | 60.09 ns |
Intrinsics | 10 | 255.40 ns |
Naive | 100 | 360.56 ns |
LINQ | 100 | 2 719.24 ns |
Vectors | 100 | 60.09 ns |
Intrinsics | 100 | 345.54 ns |
Naive | 1000 | 1 847.88 ns |
LINQ | 1000 | 12 033.78 ns |
Vectors | 1000 | 240.38 ns |
Intrinsics | 1000 | 630.98 ns |
Naive | 10000 | 18 403.72 ns |
LINQ | 10000 | 102 489.96 ns |
Vectors | 10000 | 7 316.42 ns |
Intrinsics | 10000 | 3 365.25 ns |
Naive | 100000 | 176 630.67 ns |
LINQ | 100000 | 975 998.24 ns |
Vectors | 100000 | 78 828.03 ns |
Intrinsics | 100000 | 41 269.41 ns |
Видно, что решения с Vectors и Intrinsics очень сильно выигрывают в скорости по сравнению с очевидным решением и с LINQ. Теперь надо разобраться что происходит в этих двух методах.
Рассмотрим подробнее метод Vectors:
public int Vectors() {
int vectorSize = Vector<int>.Count;
var accVector = Vector<int>.Zero;
int i;
var array = Array;
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = new Vector<int>(array, i);
accVector = Vector.Add(accVector, v);
}
int result = Vector.Dot(accVector, Vector<int>.One);
for (; i < array.Length; i++) {
result += array[i];
}
return result;
}
- int vectorSize = Vector<int>.Count; — это сколько 4х байтовых чисел мы можем поместить в вектор. Если используется аппаратное ускорение, то эта величина показывает сколько 4х-байтовых чисел можно поместить в один SIMD регистр. По сути она показывает над сколькими элементами данного типа можно проводить операции параллельно;
- accVector — вектор, в котором будет накапливаться результат функции;
var v = new Vector<int>(array, i); — загружаются данные в новый вектор v, из array, начиная с индекса i. Загрузится ровно vectorSize данных. - accVector = Vector.Add(accVector, v); — складываются два вектора.
Например в Array хранится 8 чисел: {0, 1, 2, 3, 4, 5, 6, 7} и vectorSize == 4, тогда:
В первой итерации цикла accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3}, после сложения в accVector будет: {0, 0, 0, 0} + {0, 1, 2, 3} = {0, 1, 2, 3}.
Во второй итерации v = {4, 5, 6, 7} и после сложения accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}. - Остаётся только как-то получить сумму всех элементов вектора, для этого можно применить скалярное умножение на вектор, заполненный единицами: int result = Vector.Dot(accVector, Vector<int>.One);
Тогда получится: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28. - В конце, если требуется, то досуммируются числа, которые не помещаются в последний вектор.
Если взглянуть в код метода Intrinsics:
public unsafe int Intrinsics() {
int vectorSize = 256 / 8 / 4;
var accVector = Vector256<int>.Zero;
int i;
var array = Array;
fixed (int* ptr = array) {
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = Avx2.LoadVector256(ptr + i);
accVector = Avx2.Add(accVector, v);
}
}
int result = 0;
var temp = stackalloc int[vectorSize];
Avx2.Store(temp, accVector);
for (int j = 0; j < vectorSize; j++) {
result += temp[j];
}
for (; i < array.Length; i++) {
result += array[i];
}
return result;
}
То можно увидеть, что он очень похож на Vectors за некоторым исключением:
- vectorSize задан константой. Так происходит потому что в этом методе явно используются Avx2 инструкции, которые оперируют с 256 битными регистрами. В реальном приложении должна быть проверка на то поддерживает ли текущий процессор Avx2 инструкции и если не поддерживает, вызывать другой код. Выглядит это примерно так:
if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ...
- var accVector = Vector256<int>.Zero; accVector объявлен как 256 битный вектор, заполненный нулями.
- fixed (int* ptr = Array) — в ptr заносится указатель на array.
- Дальше такие же операции как и в Vectors: загрузка данных в вектор и сложение двух векторов.
- Для суммирования элементов вектора был применён такой способ:
- создаётся массив на стэке: var temp = stackalloc int[vectorSize];
- загружается вектор в этот массив: Avx2.Store(temp, accVector);
- в цикле суммируются элементы массива.
- потом досуммируются элементы массива, которые не помещаются в последний вектор
Сравниваем два массива
Надо сравнить два массива байт. Собственно это та задача, из-за которой я начал изучать SIMD в .NET. Напишем опять несколько методов для бенчмарка, будем сравнивать два массива: ArrayA и ArrayB:
Самое очевидное решение:
public bool Naive() {
for (int i = 0; i < ArrayA.Length; i++) {
if (ArrayA[i] != ArrayB[i]) return false;
}
return true;
}
Решение через LINQ:
public bool LINQ() => ArrayA.SequenceEqual(ArrayB);
Решение через функцию MemCmp:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
static extern int memcmp(byte[] b1, byte[] b2, long count);
public bool MemCmp() => memcmp(ArrayA, ArrayB, ArrayA.Length) == 0;
С использованием векторов из System.Numerics:
public bool Vectors() {
int vectorSize = Vector<byte>.Count;
int i = 0;
for (; i < ArrayA.Length - vectorSize; i += vectorSize) {
var va = new Vector<byte>(ArrayA, i);
var vb = new Vector<byte>(ArrayB, i);
if (!Vector.EqualsAll(va, vb)) {
return false;
}
}
for (; i < ArrayA.Length; i++) {
if (ArrayA[i] != ArrayB[i])
return false;
}
return true;
}
С использованием Intrinsics:
public unsafe bool Intrinsics() {
int vectorSize = 256 / 8;
int i = 0;
const int equalsMask = unchecked((int) (0b1111_1111_1111_1111_1111_1111_1111_1111));
fixed (byte* ptrA = ArrayA)
fixed (byte* ptrB = ArrayB) {
for (; i < ArrayA.Length - vectorSize; i += vectorSize) {
var va = Avx2.LoadVector256(ptrA + i);
var vb = Avx2.LoadVector256(ptrB + i);
var areEqual = Avx2.CompareEqual(va, vb);
if (Avx2.MoveMask(areEqual) != equalsMask) {
return false;
}
}
for (; i < ArrayA.Length; i++) {
if (ArrayA[i] != ArrayB[i])
return false;
}
return true;
}
}
Результат бэнчмарка на моём компьютере:
Method | ItemsCount | Median |
---|---|---|
Naive | 10000 | 66 719.1 ns |
LINQ | 10000 | 71 211.1 ns |
Vectors | 10000 | 3 695.8 ns |
MemCmp | 10000 | 600.9 ns |
Intrinsics | 10000 | 1 607.5 ns |
Naive | 100000 | 588 633.7 ns |
LINQ | 100000 | 651 191.3 ns |
Vectors | 100000 | 34 659.1 ns |
MemCmp | 100000 | 5 513.6 ns |
Intrinsics | 100000 | 12 078.9 ns |
Naive | 1000000 | 5 637 293.1 ns |
LINQ | 1000000 | 6 622 666.0 ns |
Vectors | 1000000 | 777 974.2 ns |
MemCmp | 1000000 | 361 704.5 ns |
Intrinsics | 1000000 | 434 252.7 ns |
Весь код этих методов, думаю, понятен, за исключением двух строк в Intrinsics:
var areEqual = Avx2.CompareEqual(va, vb);
if (Avx2.MoveMask(areEqual) != equalsMask) {
return false;
}
В первой два вектора сравниваются на равенство и результат сохраняется в вектор areEqual, у которого в элемент на конкретной позиции все биты выставляются в 1, если соответствующие элементы в va и vb равны. Получается, что если векторы из байт va и vb полностью равны, то в areEquals все элементы должны равняться 255 (11111111b). Т.к. Avx2.CompareEqual является обёрткой над _mm256_cmpeq_epi8, то на сайте Intel можно посмотреть псевдокод этой операции:
Метод MoveMask из вектора делает 32-х битное число. Значениями битов являются старшие биты каждого из 32 однобайтовых элементов вектора. Псевдокод можно посмотреть здесь.
Таким образом, если какие-то байты в va и vb не совпадают, то в areEqual соответствующие байты будут равны 0, следовательно старшие биты этих байт тоже будут равны 0, а значит и соответствующие биты в ответе Avx2.MoveMask тоже будут равны 0 и сравнение с equalsMask не пройдёт.
Разберём небольшой пример, приняв что длина вектора 8 байт (чтобы писать было меньше):
- Пускай va = {100, 10, 20, 30, 100, 40, 50, 100}, а vb = {100, 20, 10, 30, 100, 40, 80, 90};
- Тогда areEqual будет равен {255, 0, 0, 255, 255, 255, 0, 0};
- Метод MoveMask вернёт 10011100b, который нужно будет сравнивать с маской 11111111b, т.к. эти маски неравны, то выходит, что и векторы va и vb неравны.
Подсчитываем сколько раз элемент встречается в коллекции
Иногда требуется посчитать сколько раз конкретный элемент встречается в коллекции, например, интов, этот алгоритм тоже можно ускорить. Напишем несколько методов для сравнения, будем искать в массиве Array элемент Item.
Самый очевидный:
public int Naive() {
int result = 0;
foreach (int i in Array) {
if (i == Item) {
result++;
}
}
return result;
}
с использованием LINQ:
public int LINQ() => Array.Count(i => i == Item);
с использованием векторов из System.Numerics.Vectors:
public int Vectors() {
var mask = new Vector<int>(Item);
int vectorSize = Vector<int>.Count;
var accResult = new Vector<int>();
int i;
var array = Array;
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = new Vector<int>(array, i);
var areEqual = Vector.Equals(v, mask);
accResult = Vector.Subtract(accResult, areEqual);
}
int result = 0;
for (; i < array.Length; i++) {
if (array[i] == Item) {
result++;
}
}
result += Vector.Dot(accResult, Vector<int>.One);
return result;
}
С использованием Intrinsics:
public unsafe int Intrinsics() {
int vectorSize = 256 / 8 / 4;
//var mask = Avx2.SetAllVector256(Item);
//var mask = Avx2.SetVector256(Item, Item, Item, Item, Item, Item, Item, Item);
var temp = stackalloc int[vectorSize];
for (int j = 0; j < vectorSize; j++) {
temp[j] = Item;
}
var mask = Avx2.LoadVector256(temp);
var accVector = Vector256<int>.Zero;
int i;
var array = Array;
fixed (int* ptr = array) {
for (i = 0; i < array.Length - vectorSize; i += vectorSize) {
var v = Avx2.LoadVector256(ptr + i);
var areEqual = Avx2.CompareEqual(v, mask);
accVector = Avx2.Subtract(accVector, areEqual);
}
}
int result = 0;
Avx2.Store(temp, accVector);
for(int j = 0; j < vectorSize; j++) {
result += temp[j];
}
for(; i < array.Length; i++) {
if (array[i] == Item) {
result++;
}
}
return result;
}
Результат бенчмарка на моём компьютере:
Method | ItemsCount | Median |
---|---|---|
Naive | 1000 | 2 824.41 ns |
LINQ | 1000 | 12 138.95 ns |
Vectors | 1000 | 961.50 ns |
Intrinsics | 1000 | 691.08 ns |
Naive | 10000 | 27 072.25 ns |
LINQ | 10000 | 113 967.87 ns |
Vectors | 10000 | 7 571.82 ns |
Intrinsics | 10000 | 4 296.71 ns |
Naive | 100000 | 361 028.46 ns |
LINQ | 100000 | 1 091 994.28 ns |
Vectors | 100000 | 82 839.29 ns |
Intrinsics | 100000 | 40 307.91 ns |
Naive | 1000000 | 1 634 175.46 ns |
LINQ | 1000000 | 6 194 257.38 ns |
Vectors | 1000000 | 583 901.29 ns |
Intrinsics | 1000000 | 413 520.38 ns |
Методы Vectors и Intrinsics полностью совпадают в логике, отличия только в реализации конкретных операций. Идея в целом такая:
- создаётся вектор mask, в котором в каждом элементе храниться искомое число;
- Загружается в вектор v часть массива и сравнивается с маской, тогда в равных элементах в areEqual будут выставлены все биты, т.к. areEqual — вектор из интов, то, если выставить все биты одного элемента, получим -1 в этом элементе ((int)(1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
- вычитается из accVector вектор areEqual и тогда в accVector будет сумма того, сколько раз элемент item встретился во всех векторах v для каждой позиции (минус на минут дают плюс).
Весь код из статьи можно найти на GitHub
Заключение
Я рассмотрел лишь очень малую часть возможностей, которые предоставляет .NET для векторизации вычислений. За полным и актуальным список доступных интринсиков в .NETCORE под x86 можно обратиться к исходному коду. Удобно, что там в C# файлах в summary каждого интринсика есть его же название из мира C, что упрощает и понимание назначения этого интринсика, и перевод уже существующих С++/С алгоритмов на .NET. Документация по System.Numerics.Vector доступна на msdn.
На мой вгляд, у .NET есть большое преимущество перед C++, т.к. JIT компиляция происходит уже на клиентской машине, то компилятор может оптимизировать код под конкретный клиентский процессор, предоставляя максимальную производительность. При этом программист для написания быстрого кода может оставаться в рамках одного языка и технологий.
Комментарии (43)
Siemargl
12.01.2019 20:29На мой вгляд, у .NET есть большое преимущество перед C++, т.к. JIT компиляция происходит уже на клиентской машине, то компилятор может оптимизировать код под конкретный клиентский процессор, предоставляя максимальную производительность.
И этот бред кочует из статьи в статью, в.т.ч по Яве, не имея никаких доказательств реального, а не возможного теоретически.
дНет будет иметь «большое преимущество перед C++», когда его хоть как то догонит =)
См и тут выше пример с memcmp, который даже с маршаллингом и без инлайна выигрывает вдвое-втрое.
В качестве пруфа предоставлю возможность транслировать код из статьи на С++ и проверить самостоятельно.T-D-K
12.01.2019 21:02+1Я считаю, что основной фактор в большой разнице в скорости memcmp и моего кода на C# в том, что memcmp писали умные люди с кучей опыта, а код на C# писал я.
0x1000000
12.01.2019 21:40Может memcmp тоже SIMD внутри использует. Что-то уж больно быстр он.
T-D-K
12.01.2019 22:26Вот декмпилированный код memcmp, который используется в моих тестах: pastebin.com/bbPqQD1N
Там только проверка на передаваемый размер, в общем случае сравнение идёт по 8 байт, объединённых в 4 группы.
Вот ассемблерный код, который генерит JIT для Intrinsics: pastebin.com/u77KXa5r
Он страшный, но я хз как его улучшить.
Тесты все ходят, в них я уверен, так что memcmp просто написан лучше, работает напрямую с памятью и мой код скорее всего где-то промахивается в кэше.ignorance
13.01.2019 14:35+1Посмотрел код pastebin.com/u77KXa5r
Очень странно.
vmovdqu ymm0,ymmword ptr [rax] — загружаем 256 байт массива ArrayA в регистр ymm0
vmovupd ymmword ptr [rbp-0B0h],ymm0 — выгружаем его обратно в память
…
vmovdqu ymm0,ymmword ptr [rax] — загружаем 256 байт массива ArrayB в регистр ymm0
vmovupd ymmword ptr [rbp-70h],ymm0 — выгружаем его обратно в память
vmovupd ymm0,ymmword ptr [rbp-0B0h] — загружаем ранее выгруженный ArrayA в регистр ymm0
vpcmpeqb ymm0,ymm0,ymmword ptr [rbp-70h] — сравниваем ArrayA (ymm0) и ArrayB (который в памяти), результат заносим в ymm0
Это не Debug, часом? Не вижу других причин для использования единственного регистра ymm0T-D-K
13.01.2019 14:40Это не Debug точно, т.к. этот asm код я получал через BenchmarkDotNet, а он отказывается работать в Debug. Данные гоняются туда-сюда из регистров в стэк и обратно как мне кажется из-за локальных переменных.
Мне тоже этот момент вчера не понравился и я переписал сравнение без локальных переменных:
byte* ptrA1 = ptrA + i; byte* ptrB1 = ptrB + i; if (Avx2.MoveMask(Avx2.CompareEqual(Avx2.LoadVector256(ptrA1), Avx2.LoadVector256(ptrB1))) != equalsMask) { return false; }
И получилось уже такое:
00007ff9`17612cfa 4863c0 movsxd rax,eax
00007ff9`17612cfd 480345d0 add rax,qword ptr [rbp-30h]
00007ff9`17612d01 c4e17e6f00 vmovdqu ymm0,ymmword ptr [rax]
00007ff9`17612d06 488b45b0 mov rax,qword ptr [rbp-50h]
00007ff9`17612d0a c4e17d7400 vpcmpeqb ymm0,ymm0,ymmword ptr [rax]
00007ff9`17612d0f c4e17dd7c0 vpmovmskb eax,ymm0
00007ff9`17612d14 83f8ff cmp eax,0FFFFFFFFh
вроде смотрится лучше, но больше прироста скорости бенчмарк не показалignorance
13.01.2019 16:33Да, загрузка ArrayB вроде уже одной инструкцией. А загрузку ArrayA не покажете?
Потому что судя по операции сравнения
vpcmpeqb ymm0,ymm0,ymmword ptr [rax]
мы опять используем единственный регистр, а ArrayA выгружается в память обратно.
Видимо, компилятор не может (пока?) оптимизировать операцию с регистрами.
Забавно, что выгрузка в память в исходном ассемблерном коде идет через операцию vmovupd, предназначенную для работы с double.T-D-K
13.01.2019 16:56Вот полный asm: pastebin.com/MCDQ7mfr
Да, загрузка ArrayB вроде уже одной инструкцией. А загрузку ArrayA не покажете?… ArrayA выгружается в память обратно.
вот я тут вас не понял
00007ff9`17612cfa 4863c0 movsxd rax,eax
00007ff9`17612cfd 480345d0 add rax,qword ptr [rbp-30h]
00007ff9`17612d01 c4e17e6f00 vmovdqu ymm0,ymmword ptr [rax]
00007ff9`17612d06 488b45b0 mov rax,qword ptr [rbp-50h]
00007ff9`17612d0a c4e17d7400 vpcmpeqb ymm0,ymm0,ymmword ptr [rax]
00007ff9`17612d0f c4e17dd7c0 vpmovmskb eax,ymm0
00007ff9`17612d14 83f8ff cmp eax,0FFFFFFFFh
Разве не так получается:
- в 17612d01 грузим в ymm0 256 бит из [rax] — часть из arrayA
- в 17612d0a используем как второй аргумент часть arrayB из памяти. Не грузим её в отдельный регистр, но это сильно бьёт по скорости? По-моему так лучше.
- в 17612d0f грузим маску в eax, потом сравниваем
Я надеюсь что компилятор «пока» не может оптимизировать это, т.к. .netcore 3 в preview ещё.ignorance
13.01.2019 17:15Да, здесь я неправ.
Отстается неясным, почему это не помогло по скорости.T-D-K
13.01.2019 17:28После среды у меня будет время свободное вечером и я полностью разберу сравню код memcmp и, которые генерирует JIT, и попытаюсь понять в чём тормоза. Если кто-нибудь не сделает это раньше.
ignorance
13.01.2019 18:09Посмотрел код memcmp. Основные затраты там в ветке if ( Size >> 5 ). Unrolled цикл, в котором обрабатывается 32 байта четыремя сравнениями int64.
В коде JIT, предположу, мешает обвязка.
ignorance
13.01.2019 17:29+1Возможно, имеет смысл кэшировать ArrayA.Length — vectorSize в локальную переменную. Там в цикле есть обращение
call 00007ff9`175eb570 get_ArrayA
Подозреваю, для вычисления длины массива на каждой итерации.
Отдельно можно попробовать инкремент указателей вместо прибавления счетчика.
dotnetdonik
12.01.2019 23:51+4И этот бред кочует из статьи в статью, в.т.ч по Яве, не имея никаких доказательств реального, а не возможного теоретически.
дНет будет иметь «большое преимущество перед C++», когда его хоть как то догонит =)
.net векторизация с оптимизациями недавними рантайма уже вполне сопоставим работает с unmanaged кодом, просто в статье этой нет сравнения с C/C++ — вот почитайте как эти же векторы для мандельброта в managed коде сравнивают с различными комбинациях c unmanaged код на C++. В целом он будет работать быстрее чем невекторизированный код на C/C++. Векторизированный нативный код хоть и быстрее, на с# для SIMD задач скейлится отлично и в режиме многопоточности работает быстрее чем однопоточный векторизированный нативный код.
www.codeproject.com/Articles/1223361/%2FArticles%2F1223361%2FBenchmarking-NET-Core-SIMD-performance-vs-Intel-IS
Если сопоставить с трудозатратами и временем на разработку такого и кода и использованием высокроуневого АПИ на С# — последний очевидно будет более предпочтительным.Siemargl
13.01.2019 09:52Я не против того, что дНет дает сопоставимые результаты по сравнению с нативным кодом, что то типа О(1).
Я против заведомо ложных утверждений, одно из которых я и вынес в цитату.
Вообще, это один из приемов софистики.
ignorance
13.01.2019 16:44+1Соглашусь, что векторизованный .net будет лучше невекторизованного С++ на больших объемах данных. Однако, вот смотрите, чем мы занимаемся в ветке комментариев выше — смотрим результат компиляции в asm. Т.е. чтобы писать эффективный код, все равно придется спускаться по абстракциям. И то, что нам приходится прибегать к unsafe — не так уж сильно отличается от С++ по безопасности и усилиям на отладку.
В этой связи не проще ли выносить узкие части на С++? Компилятор (MS) достаточно умен, чтобы разрулить регистры, и можно достичь неплохой производительности, даже не прибегая к чтению руководств от Intel и анализа показателей latency для инструкций.
Nagg
13.01.2019 00:39+1С++ может компилироваться на клиенте через llvm jit и как дотнет/ява оптимизироваться под конкретное железо но в целом к плюсам нужные ровные руки (чуть изменил и хоп — автовекторизация отвалилась) и много терпения для ожидания компиляции -_-
dotnetdonik
13.01.2019 00:55+1В случае .net jit это только малая часть от данных результатов. Понятное дело, что если векторные оптимизации сами по себе бы не дали таких результатов в managed языке. В целом все развивается вокруг идеи low alloc типов и апи, что не аллоцируют. И высокоуровневое апи для работы с ними, что не требуют писать unmanaged код. Поэтому кроме нормальной поддержки SIMD у них еще большинство апи работают через memory pooling с буферами, что не создают GC pressure со стеком(работать с которым теперь, к слову, так же можно не прибегая к unmanaged коду) или native memory — опять же это все скрыто внутри удобных высокоуровневых Апи не требующих писать тон boilerplate кода как в С/C++ — скорее всего они будут иметь какой-то perf penalty но в целом будут работать соизмеримо и выглядеть куда предпочтительней.
Nagg
13.01.2019 00:34+2Единственная проблема с System.Runtime.Intrinsics — для максимальной эффективности вам придется сильно усложнить код, к примеру прежде чем бегать векторами по циклу надо выравнить данные, вот вам пример "простой" функции на C# для сложения массива чисел https://github.com/dotnet/machinelearning/blob/287bc3e9b84f640e9b75e7b288c2663a536a9863/src/Microsoft.ML.CpuMath/AvxIntrinsics.cs#L988-L1095 ;-)
kovserg
13.01.2019 11:19.NET есть большое преимущество перед C++, т.к. JIT компиляция происходит… При этом программист для написания быстрого кода может оставаться в рамках одного языка и технологий
Чем python не устраивает и SIMD и CUDA и всё в рамках «одного языка и технологий» и не привязано гвоздями к .NET.
Как правило перед программистом стоит задача написать быстро, а не «быстрого кода». И для ускорения используются уже готовые оптимизированные библиотеки.
Тут основная проблема не в наличии векторных инструкций, а в отсутствии возможности упрощения кода, разделения алгоритма, организации представления данных, графа исполнения и оптимизаций. Что приводит к резкому усложнению кода. Попытки справиться с этим можно посмотреть в языке halide-lang.orgNagg
13.01.2019 18:49+1Вы говорите о разных вещах, ни один компилятор ни одного языка (в том числе "быстрый" питон) не сможет автоматически векторизировать сложный код и ВСЕГДА придется скатываться до использования интринсиков прямо в коде
kovserg
13.01.2019 22:20+1Вы не поняли. Для написания быстрого кода программисту не надо лезть в дебри разных архитектур. Он просто использует декомпозицию матриц, умножает вектора, решает диф.уравнения, вызывает нейронные сети на фермах видиокарт. Ему нафиг не упало лезть и программировать плисы или копаться в AVX512. Если у вас супер компьютер с 2,397,824 ядрами вы не будете колупаться на C# с такой ерундой.
Nagg
13.01.2019 22:25+1Умножение матриц и декомпозицию матриц (что это?) за вас написали на интрисиках. Эти самые интринсики и обозревал автор статьи — они для тех, кто хочет писать инструменты повверх которых такие как вы не будут задумываться о перфомансе. Нет универсального языка, который любую вашу формулу максимально оптимизирует сам
Nagg
13.01.2019 22:29+1К примеру, вы можете взять базовые System.Numerics.Matrix4x4 в дотнете и перемножить их просто mat3 = mat1 * mat2; — дотнет сам за вас их векторизует ;-)
kovserg
14.01.2019 08:18Проблема именно в том что архитектуры очень разные, и оптимизировать под каждую конкретно очень дорого и сложно. Поэтому оптимизированные библиотеки предоставляет производитель железа который эти инструкции туда добавил. И как правило он их туда добавляет не просто так, а для улучшения производительности именно этих библиотек.
software.intel.com/ru-ru/performance-libraries
developer.nvidia.com/gpu-accelerated-libraries
developer.amd.com/amd-cpu-libraries
projectne10.github.io/Ne10
www.ti.com/processors/digital-signal-processors/libraries/libraries.html
www.intel.com/content/www/us/en/software/programmable/sdk-for-opencl/overview.html
github.com/aws/aws-fpga
www.imgtec.com/developers/neural-network-sdk
…
И что бы не писать горы кода на C/C++ перед получением результата их оборачивают в библиотеки для языков типа python, что бы удобно было пользоваться тем кому нужно «ехать, а не шашечки».
И потом написание оптимизированных алгоритмов вручную это очень сложный и дорогостоящий процесс, требующий анализа огромного количества ограничений.
Попробуй-те оптимизировать реальную задачу, а не сложение векторов и вы поймёте что наличия доступа к SIMD это только вершина айсберга.
ignorance
13.01.2019 17:46+2Код с Veсtors, помимо того, что более читаем и понятен, как мне кажется, лучше соответствует духу .NET. CLR мог бы как раз в зависимости от возможностей целевой машины транслировать его в SSE2/SSE4/AVX/AVX2/AVX-512.
a-tk
13.01.2019 18:43+1Про SIMD хорошо в своё время рассказал на DotNext-е Sasha Goldshtein: www.youtube.com/watch?v=WeJ8b3WRSmM
В этом году был Егор Богатов (см. dotnext-moscow.ru ) с докладом про перфоманс-оптимизации, но видео ещё не выложено в публичный доступ.dmitry_dvm
13.01.2019 20:56Видео Егора есть на сайте дотнекста в трансляции первого дня.
По теме: почему прямая реализация быстрее linq? Что они там наворотили, что стало медленнее реализации "в лоб"?ignorance
13.01.2019 23:42Спасибо, познавательно.
Кстати, он озвучил одну из проблем intrinsics avx в .net — jit вставляет sse-инструкции vzeroupper, а смешение AVX и SSE кода, насколько я помню, может ухудшить производительность.
Kiel
В c# можно без агрегатора посчитать сумму через linq
var arr = new []{ 5, 8, 9 };
var sum = arr.Sum();
А вообще c# это не производительность.
T-D-K
T IEnumerable.Sum() возвращает тот же тип, что и в коллекции. Aggregate позволяет этого избежать, чтобы не получить переполнения.
Kiel
Хорошее замечание, правда в таком случае мы кастим весь массив, что еще сильнее бьет по производительности
AgentFire
но ведь есть
arr.Cast<long>().Sum()
.Anarh
Ваш код выбросит исключение, потому что внутри перед кастом элементы сначала приводятся к object. В данном случае можно заменить на arr.Select(i => (long)i).Sum();
T-D-K
Поддерживаю. Эта классика есть в Clr via C#. Частая проблема боксинга.
В последней строчке будет ошибка.
AgentFire
Это где это "внутри перед кастом" они приводятся к object?
T-D-K
Вот код Cast:
Вот в этой строчке: yield return (TResult)obj; и будет падение.
AgentFire
вот жеж тупость :S
firedragon
C# это энтерпрайз. С кучей легаси кода и очень широкими возможностями.
Попытка спустится на низкий уровень в той же яве, это просто боль.
Кроме этого ни что не мешает вынести критичный код в библиотеку (с + asm) и вызывать уже оттуда.
dotnetdonik
А вот люди, что полагаются на возможности SIMD из статьи ниже думают совсем иначе :)
alexr64
Fixed.