Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
SIMD (одна инструкция, несколько данных) — это аппаратная поддержка применения одной операции к нескольким частям данных параллельно с одной инструкцией. Векторизованный код обрабатывает несколько значений на итерацию вместо одного, что может значительно увеличить пропускную способность для типа числовых, научных, графических, текстовых и параллельных операций, в которых одна и та же операция повторяется через буфер. Обратной стороной является дополнительная сложность, поэтому это оправдано прежде всего тогда, когда объём входных данных достаточно велик, а выигрыш подтверждён измерениями.
.NET предоставляет несколько типов поддержки SIMD. Выберите тот, который соответствует тому, сколько контроля вам нужно и сколько сложности вы готовы взять на себя.
Типы поддержки SIMD в .NET
| API (Интерфейс программирования приложений) | Namespace | Когда его использовать |
|---|---|---|
| Вектор фиксированной цели и типы матриц | System.Numerics | Графика и геометрическая математика с 2-4 векторами элементов, матрицами, кватернионами и плоскостями. |
Vector<T> |
System.Numerics | Кроссплатформенная векторизация переменной ширины, если не нужен отдельный контроль для каждой платформы. |
Vector64<T>, Vector128<T>, Vector256<T>, Vector512<T> |
System.Runtime.Intrinsics | Кроссплатформенная векторизация с фиксированной шириной и детальным контролем. Это рекомендуемая отправная точка для новых векторизованных алгоритмов. |
| Встроенные компоненты оборудования | System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm, System.Runtime.Intrinsics.Wasm | Конкретные инструкции процессора, к которым API более высокого уровня не предоставляют доступа, чтобы выжать последние крохи производительности в часто выполняемом участке кода. |
TensorPrimitives |
System.Numerics.Tensors | Готовая векторизованная математическая обработка для диапазонов. Это выполняет векторизацию за вас. |
Там, где эти API пересекаются, их связь выражается через уровни абстракции. Универсальные типы векторов — это базовые типы обмена, которыми обмениваются другие слои, поэтому технически они находятся на самом низком уровне: Vector<T> переменной ширины, который расширяется до любой ширины, поддерживаемой используемым оборудованием, и типы фиксированной ширины от Vector64<T> до Vector512<T>. Специфичные для платформ аппаратные intrinsic-функции в System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm и System.Runtime.Intrinsics.Wasm работают с этими типами, каждая из которых напрямую соответствует отдельной инструкции процессора. Кроссплатформенные операции, доступные для обобщённых типов, находятся на уровень выше платформенно-специфичных интринсиков и для каждой целевой платформы сводятся к ним. Ещё выше находятся управляемые API, которые работают с буферами целиком: векторизованные методы в Span<T>, string и TensorPrimitives. Они опираются на нижележащие слои, поэтому вы получаете SIMD-ускорение, ничего из этого не прописывая вручную. Типы System.Numerics фиксированной формы — это вспомогательные типы, специфичные для предметной области графики и геометрии, а не часть этого стека обмена данными.
В оставшейся части этой статьи последовательно рассматриваются эти API — от самого высокого уровня к самому низкому, — а затем обсуждаются тестирование, бенчмаркинг и лучшие практики.
Векторы System.Numerics и типы матриц
Пространство имен System.Numerics предоставляет типы с SIMD-ускорением фиксированной формы:
- Vector2, Vector3и Vector4 представляет векторы из 2, 3 и 4 Single значений.
- Matrix3x2 и Matrix4x4 представляют 3x2 и 4x4 матрицы значений Single .
- Plane представляет плоскость в трехмерном пространстве.
- Quaternion представляет вектор, используемый для кодирования трехмерных поворотов.
Эти типы естественным образом подходят для графики и геометрии, а среда исполнения ускоряет операции с ними с помощью инструкций SIMD, если аппаратное обеспечение их поддерживает. В следующем примере добавляются два вектора:
Vector2 v1 = Vector2.Create(0.1f, 0.2f);
Vector2 v2 = Vector2.Create(1.1f, 2.2f);
Vector2 sum = v1 + v2;
Они также предоставляют общую математику вектора, которую вы ожидаете, например точечный продукт, расстояние и зажатие:
float dot = Vector2.Dot(v1, v2);
float distance = Vector2.Distance(v1, v2);
Vector2 clamped = Vector2.Clamp(v1, Vector2.Zero, Vector2.One);
Типы матриц поддерживают матричную математику, например транспонирование и умножение:
Matrix4x4 m1 = Matrix4x4.Create(
1.1f, 1.2f, 1.3f, 1.4f,
2.1f, 2.2f, 3.3f, 4.4f,
3.1f, 3.2f, 3.3f, 3.4f,
4.1f, 4.2f, 4.3f, 4.4f);
Matrix4x4 m2 = Matrix4x4.Transpose(m1);
Matrix4x4 product = Matrix4x4.Multiply(m1, m2);
Вектор<T>
Vector<T> представляет вектор переменной ширины примитивного числового типа. Его длина фиксирована для времени существования процесса, но значение Vector<T>.Count зависит от ЦП, выполняющего код. Компилятор Just-In-Time (JIT) обрабатывает Count как константу, поэтому циклы, написанные в нем, хорошо оптимизируют.
Vector<T> предоставляет переносимую векторизацию без написания отдельного кода для каждой платформы, но ценой того, что ширина вектора неизвестна во время компиляции. В следующем примере выполняется поэлементное сложение двух массивов:
// Illustrative: element-wise add with Vector<T>. In practice, prefer the already-accelerated
// TensorPrimitives.Add, which is optimized for every Vector<T>.IsSupported element type.
public static double[] Add(double[] left, double[] right)
{
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentOutOfRangeException.ThrowIfNotEqual(right.Length, left.Length);
double[] result = new double[left.Length];
int i = 0;
// Vector<T>.Count is a JIT-time constant, so the compiler optimizes the loop bound.
int lastVectorStart = left.Length - Vector<double>.Count;
for (; i <= lastVectorStart; i += Vector<double>.Count)
{
Vector<double> v1 = Vector.Create(left.AsSpan(i));
Vector<double> v2 = Vector.Create(right.AsSpan(i));
(v1 + v2).CopyTo(result, i);
}
// Process any remaining elements that don't fill a full vector.
// Simplified for illustration: a scalar tail isn't optimal. A vectorized
// remainder that reprocesses the last full vector avoids the per-element loop.
for (; i < left.Length; i++)
{
result[i] = left[i] + right[i];
}
return result;
}
Замечание
Этот пример иллюстрирует. Вам редко нужно писать такой цикл вручную, поскольку TensorPrimitives уже предоставляет ускоренные математические операции на основе span. Это поэлементное сложение представлено как Add, также доступны операции свёртки, такие как Sum. Эти операции выполняются с аппаратным ускорением для типов элементов, которые поддерживает Vector<T> (Vector<T>.IsSupported).
Проверка аппаратного ускорения
Типы с SIMD-ускорением работают даже на аппаратных платформах или в конфигурациях JIT, не поддерживающих SIMD, поскольку используют неускоренные программные реализации. Чтобы проверить, доступно ли ускорение, проверьте соответствующее свойство IsHardwareAccelerated:
-
Vector.IsHardwareAccelerated указывает, ускоряются ли операции
Vector<T>. -
Vector128.IsHardwareAccelerated, и соответствующие варианты
Vector64/Vector256/Vector512показывают ускорение для каждой фиксированной ширины.
JIT-компилятор подставляет эти свойства как константы, поэтому неиспользуемые ветви устраняются, а их проверка не требует затрат во время выполнения. Не кэшируйте значения; считывайте их непосредственно, где вам нужны. Это же относится к свойствам Count (например, Vector128<T>.Count), которые также являются константами времени JIT.
Большинство операций с ускорением ширины сами по себе ускоряются, но это не гарантируется для каждой операции. Например, деление с плавающей точкой может быть ускорено, тогда как целочисленное деление — нет. Когда Vector256 ускоряется, Vector128 обычно тоже ускоряется, но это не гарантируется, поэтому проверяйте каждое используемое вами значение ширины.
Tip
Если необходимая операция не ускоряется на платформе, о которую вы заботитесь или хотите создать кроссплатформенный API, отправьте проблему в dotnet/runtime. Это же относится к улучшениям кодегена.
Не каждый тип элемента действителен для каждого вектора.
Vector128<T>и его братья и сестры поддерживают примитивные числовые типы (byte, sbyteshortushortintuintlongulongfloatdoublenintи nuint) сегодня, и этот набор может увеличиться, чтобы включить другие типы в будущем. Используйте Vector128<T>.IsSupported, чтобы определить, является ли заданный T допустимым, что особенно полезно в универсальном коде.
Типы, которые не поддерживаются, например char и bool, по-прежнему могут быть векторизированы путем переосмысления буфера в качестве поддерживаемого типа того же размера. Используйте Cast, чтобы переинтерпретировать срез — например, из char в ushort — или метод вектора As<TFrom, TTo>, чтобы переинтерпретировать вектор, который у вас уже есть. Повторное толкование изменяет только тип, а не базовые биты, поэтому вы несете ответственность за сохранение правильного формирования данных: bool должно оставаться 0 или 1, а char должно оставаться допустимым единицей кода UTF-16. Если векторизованная операция может создать вне диапазонное значение, необходимо нормализовать результат, прежде чем записывать его обратно.
Кроссплатформенная векторизация с помощью Vector128
Vector128<T> — это общий знаменатель для каждой платформы, поддерживающей векторизацию, поэтому лучше всего начать. Он содержит 128-разрядный вектор: 16 байтов, 8 шортов, 4 ints/floats или 2 longs/doubles.
------------------------------128-bits---------------------------
| 64 | 64 |
-----------------------------------------------------------------
| 32 | 32 | 32 | 32 |
-----------------------------------------------------------------
| 16 | 16 | 16 | 16 | 16 | 16 | 16 | 16 |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------
Vector256<T> в два раза шире, а Vector512<T> — ещё в два раза шире. Не все аппаратные платформы поддерживают увеличенные значения ширины, поэтому в следующих примерах для обеспечения переносимости используется Vector128.
Для каждой разрядности есть обобщённый тип (Vector128<T>) для данных и необобщённый статический класс (Vector128), который содержит большинство операций, включая статические фабричные методы, такие как Create и Load. Такие операторы, как +, & и <<, — это естественный способ записи арифметических и побитовых операций; их следует предпочитать эквивалентным именованным методам, чтобы избежать ошибок, связанных с приоритетом операторов, и повысить читаемость. Для алгоритмов, зависящих от порядка байтов, используйте ветвление по IsLittleEndian, которое JIT-компилятор также сводит к константе.
Замечание
В x86/x64 Vector256<T> операции обычно рассматриваются как две независимые 128-разрядные "полосы". Для большинства поэлементных операций это незаметно, но операции, которые работают между дорожками (например, перестановки или попарные/горизонтальные операции), могут вести себя иначе или быть более затратными, чем эквивалент Vector128. Прежде чем считать, что вектор большей ширины быстрее, подтвердите это бенчмарками.
Операции пересечения полос не расширяются бесплатно
Поэлементные операции не зависят от ширины: v1 + v2 даёт один и тот же результат для каждого элемента независимо от того, являются ли v1 и v2Vector128<T> или Vector256<T> — расширение лишь позволяет за одну инструкцию обработать данные ещё одной дорожки.
Add в v = [a, b, c, d] и w = [e, f, g, h] всегда объединяет элементы с одинаковым индексом:
v: [ a | b | c | d ]
w: [ e | f | g | h ]
+ + + +
r: [a+e|b+f|c+g|d+h]
Межполосные операции не так просто масштабируются, поскольку то, какие элементы объединяются, зависит от ширины вектора. Попарная редукция объединяет смежные элементы, а не элементы с одинаковыми индексами, поэтому при расширении меняется то, какие элементы в итоге образуют пары:
v: [ a | b | c | d ]
\_____/ \_____/
round 1: [ a+b | c+d | a+b | c+d ]
\_________________/
round 2: [ S | S | S | S ] (S = a+b+c+d)
Это именно то, что делает горизонтальная свёртка: суммирует элементы вектора за два этапа попарного сложения. На x86/x64 этого можно добиться с помощью Vector128<float> (4 элемента) и двух вызовов HorizontalAdd:
// Sums all four elements with two rounds of pairwise horizontal adds.
// HorizontalAdd(v, v) on [a, b, c, d] gives [a+b, c+d, a+b, c+d]; a second round
// collapses that to the full sum in every element.
public static float SumVector128(Vector128<float> v)
{
Debug.Assert(Sse3.IsSupported);
Vector128<float> step1 = Sse3.HorizontalAdd(v, v);
Vector128<float> step2 = Sse3.HorizontalAdd(step1, step1);
return step2.ToScalar();
}
Примените тот же шаблон с двумя вызовами к Vector256<float> (8 элементов), и кажется, что всё правильно, — но это не так.
HorizontalAdd не работает в 256-разрядном векторе; он повторяет попарный шаблон независимо в пределах каждой 128-разрядной полосы. После двух раундов вы получаете сумму нижней половины вектора (элементы 0–3), распространённую на все элементы нижней половины, и сумму верхней половины вектора (элементы 4–7), распространённую на все элементы верхней половины, а не сумму всех восьми элементов:
// The same two-round pattern on Vector256<float> looks like it should sum all eight
// elements, but Avx.HorizontalAdd repeats the pairwise pattern independently within
// each 128-bit lane. The result holds the lower lane's sum (elements 0-3) broadcast
// across the lower lane and the upper lane's sum (elements 4-7) broadcast across the
// upper lane -- ToScalar only returns the lower lane's partial sum, not the total.
public static float SumVector256Naive(Vector256<float> v)
{
Debug.Assert(Avx.IsSupported);
Vector256<float> step1 = Avx.HorizontalAdd(v, v);
Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);
return step2.ToScalar();
}
Чтобы получить правильный итог, явно учитывайте границу между лейнами: считывайте частичную сумму каждого лейна с помощью GetLower/GetUpper и затем складывайте их — GetLower и GetUpper разделяют вектор на первую и вторую половины:
------------------------------128-bits---------------------------
| LOWER | UPPER |
-----------------------------------------------------------------
| 32 | 32 | 32 | 32 |
-----------------------------------------------------------------
| 16 | 16 | 16 | 16 | 16 | 16 | 16 | 16 |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------
// Getting the full sum needs an explicit step to cross the lane boundary: read each
// lane's partial sum out with GetLower/GetUpper and add them together.
public static float SumVector256(Vector256<float> v)
{
Debug.Assert(Avx.IsSupported);
Vector256<float> step1 = Avx.HorizontalAdd(v, v);
Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);
Vector128<float> lower = step2.GetLower();
Vector128<float> upper = step2.GetUpper();
return lower.ToScalar() + upper.ToScalar();
}
Этот дополнительный шаг является реальной стоимостью пересечения полос. Алгоритм с обработкой поперёк линий данных нельзя просто так без издержек масштабировать на более широкие векторы, как поэлементный алгоритм, — сначала измерьте производительность, а уже потом делайте вывод, что более широкий вектор быстрее.
Распространенные операции
Vector128 и его более широкие братья и сестры предоставляют большую поверхность API. Вам не нужно запоминать всё — достаточно знать категории и уточнять детали по мере необходимости. Каждая операция имеет резервный вариант программного обеспечения для платформ, которые не могут ускорить его. В следующей таблице рассматриваются практически все поверхности.
| Category | Что делает | Репрезентативные API |
|---|---|---|
| Constants | Предопределенные векторы констант |
Zero, OneNegativeOneAllBitsSetIndicesSignSequenceEPiTauEpsilonNaNPositiveInfinityNegativeInfinityNegativeZero |
| Создание | Распространить скалярное значение, задать элементы или сгенерировать последовательность |
Create, CreateScalar, CreateScalarUnsafeCreate(ReadOnlySpan<T>)CreateSequenceCreateGeometricSequenceCreateHarmonicSequenceCreateAlternatingSequence |
| Загрузка и хранение | Перемещение данных между памятью и вектором |
Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAligned, StoreAlignedNonTemporal, CopyTo, TryCopyTo |
| Arithmetic | Поэлементные математические операции и свёртки |
Add (x + y), Subtract (x - y), Multiply (x * y), Divide (x / y), Negate (-x), AddSaturate, SubtractSaturate, Abs, Sqrt, FusedMultiplyAdd, Dot, Sum |
| Битовые операции | Побитовая логика и сдвиги |
BitwiseAnd (x & y), BitwiseOr (x \| y), Xor (x ^ y)AndNot (x & ~y)OnesComplement (~x)ShiftLeft (x << n)ShiftRightArithmetic (x >> n)ShiftRightLogical (x >>> n) |
| Минимум, максимум и ограничение | Минимальное, максимальное и диапазонное зацепирование элементов |
Min, MaxClampMinMagnitudeMaxMagnitudeMinNumberMaxNumberMinMagnitudeNumberMaxMagnitudeNumber |
| Rounding | Округление каждого элемента до целочисленного значения |
Ceiling, Floor, Round, Truncate |
| Математические функции | Знак, интерполяция, угол и трансцендентные вспомогательные средства |
CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2 |
| Comparison | Сравните каждый элемент; результатом является векторная маска, а не то, что возвращает оператор bool |
Equals, , GreaterThanGreaterThanOrEqual, LessThanLessThanOrEqual |
| Classification | Предикаты для каждого элемента по строке чисел, каждая из которых возвращает маску вектора |
IsNaN, IsFinite, IsInfinityIsPositiveInfinityIsNegativeInfinityIsIntegerIsEvenIntegerIsOddIntegerIsNegativeIsPositiveIsNormalIsSubnormalIsZero |
| Редукции сравнения | Свести поэлементное сравнение к одному bool |
EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAll, LessThanAny, LessThanOrEqualAll, LessThanOrEqualAny |
| Предикаты целого вектора | Сведите вектор к bool: равны ли значению все, какие-либо или ни один из элементов, либо (в формах WhereAllBitsSet) установлены ли все биты. Используйте их вместо преобразования маски в индекс |
All, Any, None, AllWhereAllBitsSet, AnyWhereAllBitsSet, NoneWhereAllBitsSet |
| Search | Подсчитывать или находить элементы по значению, или (формы WhereAllBitsSet) задавать элементы в маске |
Count, IndexOf, LastIndexOf, CountWhereAllBitsSet, IndexOfWhereAllBitsSet, LastIndexOfWhereAllBitsSet |
| Маска для индексирования | Превратите маску сравнения в скалярную битовую маску и проверьте ее |
ExtractMostSignificantBits с TrailingZeroCount или LeadingZeroCount |
| Отбор | Смешайте два вектора в соответствии с маской, бит по биту |
ConditionalSelect(x, y, z), эквивалентно (y & x) \| (z & ~x) |
| Conversion | Изменение числового типа, вычисление новых значений (например, int на float) |
ConvertToInt32, ConvertToInt64, ConvertToUInt32, ConvertToUInt64, ConvertToSingle, ConvertToDouble |
| Расширение и сужение | Разделение элементов на более широкий тип или упаковка их в более узкий |
Widen, , WidenLowerWidenUpper, NarrowNarrowWithSaturation |
| Переосмысление | Переосмысление битов в качестве другого типа элемента, не изменяя их |
As<TFrom, TTo>, AsByte, AsInt32, AsSingle и другие формы элемента As* |
| Межоперационное взаимодействие с System.Numerics | Переинтерпретация между Vector128<T> и числовыми типами фиксированной размерности |
AsVector, AsVector2, AsVector3AsVector4AsPlaneAsQuaternionAsVector128AsVector128Unsafe |
| Дозаказ | Переупорядочение элементов по индексу или переключение двух векторов |
Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper |
| Доступ к полосе | Чтение или замена отдельных элементов и половинок или изменение размера вектора |
GetElement, WithElement, ToScalarGetLowerGetUpperWithLowerWithUpperToVector256 |
Tip
Несколько операций имеют Estimate и Native варианты, например, MultiplyAddEstimate, , ClampNativeMinNative, , MaxNativeShuffleNativeи ConvertToInt32Native. Они соответствуют более быстрой аппаратной инструкции, которая жертвует некоторой точностью или отказывается от гарантии IEEE для граничных случаев (например, при обработке NaN), поэтому прибегать к ним стоит только тогда, когда бенчмарк показывает, что именно точная форма является узким местом и менее строгая семантика допустима.
Замечание
Vector256.Shuffle обрабатывает входные данные как единый 256-битный вектор, тогда как платформенно-зависимый Avx2.Shuffle работает с двумя независимыми 128-битными дорожками. Кроссплатформенный API — более переносимый вариант, но при переносе вручную написанных intrinsic-функций убедитесь, что он обеспечивает нужное вам поведение.
Структура пути кода
Векторизованный метод обычно разветвляется на отдельный путь для каждой ширины вектора, а также на скалярный вариант для небольших входных данных и оборудования без аппаратного ускорения. Чтобы использовать самый большой вектор, поддерживаемый оборудованием, сначала проверьте самый широкий вектор и выполните следующие действия:
// Sums a buffer, choosing the widest vector the hardware and element type support.
public static T Sum<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
// The widest-first order continues with the Vector512 and Vector256 paths, which belong
// here ahead of the Vector128 block below. They're identical to it aside from the wider
// type (for example, Vector512<T> with Vector512.Create and Vector512.Sum), so they're
// omitted for brevity:
//
// if (Vector512.IsHardwareAccelerated && Vector512<T>.IsSupported)
// {
// if (buffer.Length >= Vector512<T>.Count)
// {
// return SumVector512(buffer);
// }
// return SumVectorSmall(buffer);
// }
//
// if (Vector256.IsHardwareAccelerated && Vector256<T>.IsSupported)
// {
// if (buffer.Length >= Vector256<T>.Count)
// {
// return SumVector256(buffer);
// }
// return SumVectorSmall(buffer);
// }
if (Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported)
{
if (buffer.Length >= Vector128<T>.Count)
{
return SumVector128(buffer);
}
return SumVectorSmall(buffer);
}
return SumScalar(buffer);
}
Внешняя проверка для каждой ширины объединяет Vector128.IsHardwareAccelerated (константу времени JIT-компиляции, указывающую, ускоряет ли платформа эту ширину) с Vector128<T>.IsSupported (допустим ли тип элемента T для этой ширины). Внутри поддерживаемого блока сравните длину входных данных с Count, чтобы выбрать между векторизованной ветвью и резервным вариантом для небольших входных данных. Метод обобщён по T, а блоки Vector256 и Vector512 — идентичные блоку Vector128, но использующие более широкий тип, — для краткости изложения показаны закомментированными.
Существует два отдельных резервных варианта. Буфер, который слишком мал даже для самого узкого вектора, но на ускоренном оборудовании используется SumVectorSmall — явная таблица переходов switch, которая обрабатывает каждую возможную длину подвектора без цикла:
// Sums a buffer smaller than the widest vector. The complete "optimal" shape dispatches on the
// element width so each width uses a switch jump table sized to the number of elements that fit
// in the widest vector (63 for byte, 31 for short, 15 for int/float, 7 for long/double).
private static T SumVectorSmall<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
// sizeof(T) is a JIT constant, so only the matching branch survives for a given T.
if (sizeof(T) == 4)
{
return SumVectorSmall4(buffer);
}
// The 1-, 2-, and 8-byte tables share the shape below, sized for their element width.
// They're omitted for brevity, so those widths fall back to a scalar loop here:
//
// if (sizeof(T) == 1) return SumVectorSmall1(buffer); // switch over lengths 0..63
// if (sizeof(T) == 2) return SumVectorSmall2(buffer); // switch over lengths 0..31
// if (sizeof(T) == 8) return SumVectorSmall8(buffer); // switch over lengths 0..7
return SumScalar(buffer);
}
private static T SumVectorSmall4<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Debug.Assert(buffer.Length < Vector512<T>.Count);
T result = T.Zero;
// A 4-byte element gives Count == 4/8/16 for Vector128/256/512, so a remainder can be up to
// 15 elements. The larger cases fold the leftover with the widest vector that fits, using two
// overlapping loads (one from the start, one from the end) rather than recursing—the shape
// TensorPrimitives uses. The loads overlap for lengths that aren't an exact multiple of the
// width, so the tail is masked down to the additive identity before it's summed. That mask is
// only needed because addition is non-idempotent; an idempotent operation such as a search
// could fold the overlapping tail in directly.
switch (buffer.Length)
{
// One or two Vector256's worth of data.
case 15:
case 14:
case 13:
case 12:
case 11:
case 10:
case 9:
case 8:
{
Vector256<T> beg = Vector256.Create(buffer);
Vector256<T> end = Vector256.Create(buffer.Slice(buffer.Length - Vector256<T>.Count));
Vector256<T> msk = CreateRemainderMask256<T>(buffer.Length - Vector256<T>.Count);
end = Vector256.ConditionalSelect(msk, end, Vector256<T>.Zero);
result = Vector256.Sum(beg + end);
break;
}
// One or two Vector128's worth of data.
case 7:
case 6:
case 5:
case 4:
{
Vector128<T> beg = Vector128.Create(buffer);
Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));
Vector128<T> msk = CreateRemainderMask128<T>(buffer.Length - Vector128<T>.Count);
end = Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);
result = Vector128.Sum(beg + end);
break;
}
// Smaller than a single vector: each case falls through to the next, accumulating one
// element per label.
case 3:
{
result += buffer[2];
goto case 2;
}
case 2:
{
result += buffer[1];
goto case 1;
}
case 1:
{
result += buffer[0];
goto case 0;
}
case 0:
{
break;
}
}
return result;
}
// Builds a mask whose last `keepLast` lanes are all-bits-set and the rest zero, so an overlapping
// tail load can be folded in without double-counting the lanes the head already covered.
// TensorPrimitives uses an internal table-based helper. The mask is only a bit pattern keyed on
// lane width, so it's built with the same-width integer Indices ([0, 1, 2, ...]) and reinterpreted
// to T: integer comparisons are cheaper than floating-point ones, so a float/double table would
// still compare as int/long rather than in its own element type.
private static Vector256<T> CreateRemainderMask256<T>(int keepLast)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Vector256<int> firstKept = Vector256.Create(Vector256<int>.Count - keepLast);
return Vector256.GreaterThanOrEqual(Vector256<int>.Indices, firstKept).As<int, T>();
}
private static Vector128<T> CreateRemainderMask128<T>(int keepLast)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Vector128<int> firstKept = Vector128.Create(Vector128<int>.Count - keepLast);
return Vector128.GreaterThanOrEqual(Vector128<int>.Indices, firstKept).As<int, T>();
}
sizeof(T) также является константой на этапе JIT-компиляции, поэтому SumVectorSmall выполняет диспетчеризацию по ширине элемента к таблице, рассчитанной на число элементов в самом широком векторе — тот же подход использует TensorPrimitives. (Если включена новая модель безопасности памяти, выражения sizeof(T) для параметра типа с ограничением unmanaged разрешены в безопасном коде.) Показана только 4-байтовая таблица; 1-, 2- и 8-байтовые таблицы имеют ту же структуру. В более крупных случаях оставшуюся часть сворачивают с помощью Vector256 или Vector128, используя два перекрывающихся чтения — одно с начала, другое с конца, — так что обработка более широкого остатка для опущенных путей Vector512/Vector256 находится прямо в таблице переходов. Две операции загрузки перекрываются всякий раз, когда длина не является точным кратным ширины, поэтому хвост маскируется в аддитивный нейтральный элемент с помощью ConditionalSelect перед суммированием. Такая маска необходима только потому, что операция добавления не является идемпотентной; идемпотентная операция, такая как поиск, могла бы непосредственно объединить перекрывающийся хвост. Буфер на оборудовании, где векторизация полностью отсутствует, сводится к SumScalar обычному скалярному циклу.
Цикл по входным данным и обработка остальных элементов
Чтобы обработать буфер, размер которого больше, чем один вектор, обрабатывайте его по одному вектору за итерацию, а затем обработайте оставшиеся элементы, не заполняющие целый вектор. Надежный способ обработки этого хвоста заключается в повторной обработке последних значений элементов полного вектора, перекрывая некоторые циклы, которые уже обработаны, что позволяет избежать отдельного скалярного эпилога. Нужно ли исправлять это перекрытие, зависит от операции.
Неидемпотентная операция, например суммирование, будет учитывать перекрывающиеся элементы дважды, поэтому перед включением в свёртку их следует замаскировать до нейтрального элемента операции. Используйте этот параметр, если каждый элемент должен внести свой вклад ровно один раз:
// Sums a buffer with an unrolled vector loop plus a masked, jump-table remainder.
private static T SumVector128<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported);
Debug.Assert(buffer.Length >= Vector128<T>.Count);
// Preload the last full vector, overlapping the tail. Any sub-vector remainder is folded in
// from here (masked) by case 0 of the switch below, so the loop never falls out to a separate
// scalar tail—the same shape TensorPrimitives uses.
Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));
// A production implementation would also align the buffer to a vector boundary and, for
// very large inputs, use non-temporal loads/stores so the data doesn't evict useful
// cache lines. Both are omitted here; see TensorPrimitives for a complete treatment.
Vector128<T> sum = Vector128<T>.Zero;
// Only pay for the four independent accumulators when there's enough data to unroll;
// smaller payloads skip straight to the remainder below. Four vectors per iteration lets
// the accumulators pipeline; Vector128.Create reads the first Vector128<T>.Count elements.
if (buffer.Length >= Vector128<T>.Count * 4)
{
Vector128<T> sum0 = Vector128<T>.Zero;
Vector128<T> sum1 = Vector128<T>.Zero;
Vector128<T> sum2 = Vector128<T>.Zero;
Vector128<T> sum3 = Vector128<T>.Zero;
do
{
sum0 += Vector128.Create(buffer);
sum1 += Vector128.Create(buffer.Slice(Vector128<T>.Count));
sum2 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
sum3 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 3));
buffer = buffer.Slice(Vector128<T>.Count * 4);
}
while (buffer.Length >= Vector128<T>.Count * 4);
// Combine pairwise so the two independent adds can pipeline.
sum = (sum0 + sum1) + (sum2 + sum3);
}
// Split the remainder into its full vectors and a sub-vector tail. The full vectors fall
// through the jump table; the tail lands in case 0, where the preloaded end is masked so only
// the trailing elements the full vectors didn't already cover are added.
(int blocks, int trailing) = Math.DivRem(buffer.Length, Vector128<T>.Count);
switch (blocks)
{
case 3:
{
sum += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
goto case 2;
}
case 2:
{
sum += Vector128.Create(buffer.Slice(Vector128<T>.Count));
goto case 1;
}
case 1:
{
sum += Vector128.Create(buffer);
goto case 0;
}
case 0:
{
Vector128<T> msk = CreateRemainderMask128<T>(trailing);
sum += Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);
break;
}
}
// Horizontally add the lanes into a single scalar.
return Vector128.Sum(sum);
}
Эта версия добавляет для развёрнутого цикла проверку с помощью if, так что небольшие блоки данных полностью обходят обработку через четыре аккумулятора и сразу переходят к обработке остатка. Когда данных достаточно, do/while накапливает четыре вектора за итерацию в независимых аккумуляторах, что позволяет процессору конвейеризовать операции сложения, а затем объединяет их попарно. Затем switch таблица переходов обрабатывает оставшиеся от нуля до трёх полных векторов и, в case 0, хвост подвектора: при этом повторно используется полный вектор, предварительно загруженный с конца буфера и перекрывающий уже обработанные элементы, а это перекрытие маскируется до аддитивного нуля с помощью ConditionalSelect, так что хвост остаётся векторизованным, а не переходит к скалярному циклу. Как и раньше, Vector128.Create считывает Vector128<T>.Count элементов из span. JIT устраняет проверку выхода за границы для Span при типичных шаблонах доступа, поэтому Create — вполне подходящий вариант по умолчанию даже в горячем цикле; LoadUnsafe (рассматривается далее) — более низкоуровневая альтернатива на случай, если вы обходите буфер через управляемую ссылку. Для очень больших входных данных полная реализация должна также выравнивать буфер и использовать нетемпоральные загрузки и записи, чтобы избежать вытеснения полезных строк кэша; оба этих аспекта здесь опущены и полностью рассматриваются в TensorPrimitives.
Такая идемпотентная операция, как поиск значения, может без последствий повторно обработать перекрывающийся участок, поэтому последний вектор включается напрямую, без маски:
// Idempotent search that re-processes the final vector instead of a scalar loop.
public static bool Contains(ReadOnlySpan<int> buffer, int searched)
{
Debug.Assert(Vector128.IsHardwareAccelerated);
Vector128<int> values = Vector128.Create(searched);
ReadOnlySpan<int> remaining = buffer;
while (remaining.Length >= Vector128<int>.Count)
{
if (Vector128.EqualsAny(Vector128.Create(remaining), values))
{
return true;
}
remaining = remaining.Slice(Vector128<int>.Count);
}
if (remaining.IsEmpty)
{
return false;
}
// A partial vector remains. When the buffer holds at least one full vector,
// re-check the last one (overlapping the tail); otherwise scan the few elements directly.
if (buffer.Length >= Vector128<int>.Count)
{
Vector128<int> tail = Vector128.Create(buffer.Slice(buffer.Length - Vector128<int>.Count));
return Vector128.EqualsAny(tail, values);
}
foreach (int value in remaining)
{
if (value == searched)
{
return true;
}
}
return false;
}
Предупреждение
Неправильная обработка остатка — распространённый источник ошибок. Цикл, который считывает после конца буфера, создает недетерминированные результаты и может завершиться сбоем. В наборе тестов среды выполнения используется вспомогательная функция BoundedMemory, которая размещает страницу без доступа сразу после буфера, поэтому любое чтение за пределами буфера во время тестирования вызывает AccessViolationException. Всегда охватывайте оставшуюся логику, включая буферы, длина которых не является кратной ширины вектора.
Безопасная загрузка и хранение векторов
Для большинства кода Vector128.Create(span) и CopyTo является самым простым способом перемещения данных между диапазоном и вектором, а JIT обеспечивает их эффективность. Если вам нужны низкоуровневые операции загрузки и записи — например, для обхода буфера по управляемой ссылке, — отдавайте предпочтение перегрузкам LoadUnsafe и StoreUnsafe, принимающим управляемую ссылку и смещение элемента типа nuint. В отличие от перегрузок на основе указателей Load/Store, они не требуют закрепления буфера, и, в отличие от арифметики с необработанными ссылками, не требуют вручную увеличивать ref. Обе альтернативы легко реализовать неправильно, что приведёт к брешам в работе сборщика мусора или к нарушениям доступа.
Поэтому пустые буферы не создаются, получите начальную ссылку из GetReference (или GetArrayDataReference для массивов), а не ref span[0].
Important
В арифметике смещений используется беззнаковый тип nuint. Всегда проверяйте длину буфера перед вычислением смещения, например buffer.Length - Vector128<int>.Count. Если буфер меньше одного вектора, при этом вычитании происходит арифметическое заимствование, в результате чего получается огромное значение, и цикл читает недопустимую область памяти.
Встроенные компоненты оборудования для конкретной платформы
Если определённая инструкция процессора даёт вам преимущество, недоступное через переносимые API, обратитесь к аппаратным интринсикам в System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm и System.Runtime.Intrinsics.Wasm. У каждого встроенного класса есть свойство IsSupported (также являющееся константой JIT), поэтому можно проверять специализированный путь, а в остальных случаях использовать переносимый код:
// Illustrates per-platform lightup. The portable '(vector & mask) == Zero' below
// already lowers optimally, so prefer it unless a specific instruction measurably wins.
public static bool AllBitsClear(Vector128<byte> vector, Vector128<byte> mask)
{
if (Sse41.IsSupported)
{
// x86/x64: a single ptest instruction.
return Sse41.TestZ(vector, mask);
}
else if (AdvSimd.Arm64.IsSupported)
{
// Arm64: AND, then reduce the maximum byte across every lane.
Vector128<byte> anded = AdvSimd.And(vector, mask);
return AdvSimd.Arm64.MaxAcross(anded).ToScalar() == 0;
}
else if (PackedSimd.IsSupported)
{
// WebAssembly: AND, then test whether any lane is non-zero.
return !PackedSimd.AnyTrue(PackedSimd.And(vector, mask));
}
else
{
// Portable fallback for any other platform.
return (vector & mask) == Vector128<byte>.Zero;
}
}
Приведенный выше метод показывает, как осветить пути кода для каждой архитектуры, если вы хотите их, но это намеренно простой пример: вы на самом деле не нуждаетесь в нем. Переносимое выражение (vector & mask) == Vector128<byte>.Zero уже компилируется в оптимальную инструкцию на каждой платформе (например, ptest на x86/x64), поэтому оно выполняет ту же работу, что и ветви, написанные вручную, но без лишней сложности. Прибегайте к явным intrinsic-функциям только тогда, когда конкретная инструкция по результатам измерений работает лучше, чем то, что генерируют переносимые API.
Встроенные компоненты оборудования требуют отдельной реализации для каждого набора инструкций, поэтому они рассматриваются как оптимизация измеренных горячих путей, а не по умолчанию.
Vector128
/
Vector256 API-интерфейсы уже ниже до эффективных инструкций на каждой платформе, и на практике сложный код инструкции не всегда выигрывает. Прежде чем брать на себя дополнительные затраты на сопровождение, подтвердите наличие различий с помощью бенчмарка.
Математика более высокого уровня с TensorPrimitives
Если вам нужна векторизованная математика для Span и вы не хотите самостоятельно писать циклы, TensorPrimitives предоставляет большой набор числовых операций — поэлементные арифметические операции, экспоненциальные функции и операции свёртки, такие как скалярное произведение и косинусное сходство, — которые уже внутренне векторизованы. Он доступен в пакете NuGet System.Numerics.Tensors .
// Computes result = (left * right) + addend over the whole span, vectorized internally.
public static float[] MultiplyAdd(float[] left, float[] right, float[] addend)
{
float[] result = new float[left.Length];
TensorPrimitives.Multiply(left, right, result);
TensorPrimitives.Add(result, addend, result);
return result;
}
// Higher-level reductions are available too.
public static float CosineSimilarity(float[] left, float[] right) =>
TensorPrimitives.CosineSimilarity(left, right);
Для нагрузок ИИ и численных вычислений TensorPrimitives часто обеспечивает большую часть преимуществ SIMD-кода, написанного вручную, без какой-либо сопутствующей сложности.
Проверка всех путей кода
Поскольку у векторизованного метода есть несколько вариантов выполнения кода, тесты должны охватывать каждый из них: вариант с Vector256, вариант с Vector128 и скалярный вариант, причем каждый из них должен проверяться как на входных данных, достаточно больших, чтобы получить от него выигрыш, так и на слишком малых для этого. Вы можете изменить размер входных данных в тестах, но вы не можете переключать аппаратное ускорение на уровне теста. Вместо этого управляйте переменными среды перед началом процесса:
- Установите
DOTNET_EnableAVX2=0, чтобы Vector256.IsHardwareAccelerated возвращалfalse. - Установите
DOTNET_EnableHWIntrinsic=0, чтобы полностью отключить интринсики, и тогдаVector128,Vector64иVector<T>будут сообщать об отсутствии ускорения.
Чтобы проверить все пути выполнения на одной машине, запустите набор тестов один раз без переопределений, один раз с DOTNET_EnableAVX2=0 и один раз с DOTNET_EnableHWIntrinsic=0. Альтернатива — прогонять это на достаточно разнообразном оборудовании, чтобы охватить их.
Ручки конфигурации набора инструкций
Помимо этих двух, среда выполнения распознает ручку для каждой логической группировки наборов инструкций, каждая из которых имеет DOTNET_префикс. Одна ручка может охватывать несколько связанных наборов инструкций—EnableAVX2 например, шлюзы AVX2 вместе с BMI1, BMI2, F16C, FMA, LZCNT и MOVBE. Установка ручки в положение 0 отключает всю свою группу и всё, что расположено поверх неё. Если установить это значение в 1 (по умолчанию для большинства случаев), группа будет разрешена, но оборудование всё равно должно действительно её поддерживать — если включить параметр, который не поддерживается текущим CPU, это будет проигнорировано, поэтому вы можете лишь сузить набор используемых инструкций, но никогда не сможете принудительно включить неподдерживаемую инструкцию и тем самым всё сломать.
DOTNET_EnableHWIntrinsic=0 — это радикальное средство: оно отключает всё вплоть до базового уровня, поэтому Vector128, Vector64 и Vector<T> сообщают об отсутствии ускорения, и код переходит на программный путь выполнения.
Important
Это диагностические средства, предназначенные в первую очередь для тестирования и валидации — проверки всех путей выполнения кода, воспроизведения проблемы, характерной для конкретного оборудования, или подтверждения срабатывания резервного механизма. Они не предназначены для общего или производственного использования, и они не являются контрактом стабильности. Следующий набор — это то, что распознаёт .NET 11; в более ранних выпусках использовался другой набор — в частности, были перенастроены параметры baseline и AVX-512, поэтому сверяйте эти имена с версией среды выполнения, на которую вы ориентируетесь.
Эти средства также имеют ограничения на то, что они достигают. Поскольку они влияют на принятие решений JIT, они не затрагивают код, который уже был заранее скомпилирован с помощью ReadyToRun или Native AOT, и не обязательно затрагивают внутренние подпрограммы, которые сама среда выполнения и основные библиотеки используют. Рассматривайте их как способ управлять собственным JIT-компилируемым кодом, а не как глобальный выключатель для набора инструкций.
Базовый коммутатор и ограничения ширины применяются для каждой архитектуры:
Knob (DOTNET_ префикс) |
По умолчанию | Effect |
|---|---|---|
EnableHWIntrinsic |
1 |
Главный коммутатор для всех встроенных компонентов оборудования; 0 принудительно задает полный путь к программному обеспечению. |
MaxVectorTBitWidth |
системное значение по умолчанию | Ограничение до максимальной ширины Vector<T> в битах; значение ниже 128 означает системное значение по умолчанию. |
PreferredVectorBitWidth |
системное значение по умолчанию | Ограничивает максимальный размер в битах для вектора фиксированной ширины, о котором сообщает IsHardwareAccelerated; значение ниже 128 означает, что используется системное значение по умолчанию. |
Системное значение по умолчанию для MaxVectorTBitWidth может быть уже, чем полностью поддерживает оборудование, поэтому Vector<T> не расширяется автоматически до максимально широкого доступного вектора. Например, Vector512<T>.IsHardwareAccelerated может быть true, в то время как Vector<T> остаётся 256-битным; задайте DOTNET_MaxVectorTBitWidth=512, чтобы Vector<T> использовал более широкий формат.
PreferredVectorBitWidth ограничивает максимальную ширину вектора, о которой сообщается в IsHardwareAccelerated. Если снизить это значение ниже поддерживаемого оборудованием уровня, более широкие разрядности будут отключены: на машине, поддерживающей 512-битные векторы, DOTNET_PreferredVectorBitWidth=256 приводит к тому, что Vector512<T>.IsHardwareAccelerated сообщает false. Это общий параметр, но на данный момент только x86/x64 поддерживает разрядности выше 128, поэтому заметный эффект он оказывает только там.
Каждая логическая группировка наборов инструкций x86/x64 также имеет собственный коммутатор:
Knob (DOTNET_ префикс) |
По умолчанию | Gates |
|---|---|---|
EnableAVX |
1 |
AVX и зависимые |
EnableAVX2 |
1 |
AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE и зависимые |
EnableAVX512 |
1 |
AVX-512 F+BW+CD+DQ+VL и зависимые компоненты |
EnableAVX512BMM |
1 |
AVX-512 BMM |
EnableAVX512v2 |
1 |
AVX-512 IFMA+VBMI |
EnableAVX512v3 |
1 |
AVX-512 BITALG+VBMI2+VPOPCNTDQ+VNNI |
EnableAVX10v1 |
1 |
AVX10.1 |
EnableAVX10v2 |
0 |
AVX10.2 |
EnableAPX |
0 |
APX (расширенные регистры общего назначения) |
EnableAES |
1 |
AES, PCLMULQDQ |
EnableAVX512VP2INTERSECT |
1 |
AVX-512 VP2INTERSECT |
EnableAVXIFMA |
1 |
AVX-IFMA |
EnableAVXVNNI |
1 |
AVX-VNNI |
EnableAVXVNNIINT |
1 |
VEX AVX-VNNI-INT8 и AVX-VNNI-INT16 |
EnableGFNI |
1 |
GFNI |
EnableSHA |
1 |
SHA |
EnableVAES |
1 |
VAES, VPCLMULQDQ |
EnableWAITPKG |
1 |
WAITPKG |
EnableX86Serialize |
1 |
X86 SERIALIZE |
В Arm64 каждый логический набор наборов инструкций имеет собственный коммутатор:
Ручка (с префиксом DOTNET_) |
По умолчанию | Gates |
|---|---|---|
EnableArm64Aes |
1 |
AES (Стандарт шифрования данных) |
EnableArm64Atomics |
1 |
Атомарные операции LSE (Large System Extensions) |
EnableArm64Crc32 |
1 |
CRC32 |
EnableArm64Dczva |
1 |
DC ZVA обнуление кэша |
EnableArm64Dp |
1 |
Dot Product |
EnableArm64Rdm |
1 |
Умножение-накопление с удвоением и округлением (RDM) |
EnableArm64Sha1 |
1 |
SHA1 |
EnableArm64Sha256 |
1 |
SHA256 |
EnableArm64Rcpc |
1 |
Согласование выпуска, согласованное с процессором упорядочивание (RCpc) |
EnableArm64Rcpc2 |
1 |
RCpc2 |
EnableArm64Cssc |
0 |
Общее сжатие коротких последовательностей (CSSC) |
EnableArm64Sve |
1 |
Масштабируемое расширение вектора (SVE) |
EnableArm64Sve2 |
1 |
SVE2 |
EnableArm64Sha3 |
1 |
SHA3 |
EnableArm64Sm4 |
1 |
SM4 |
EnableArm64SveAes |
1 |
SVE AES |
EnableArm64SveSha3 |
1 |
SVE SHA3 |
EnableArm64SveSm4 |
1 |
SVE SM4 |
Параметр со значением по умолчанию 0 (например, EnableAVX10v2 или EnableArm64Cssc) управляет включением набора инструкций, который ещё только вводится в эксплуатацию, поэтому он остаётся отключённым, пока вы явно не включите его.
Тест для подтверждения победы
Векторизация добавляет сложности, поэтому, прежде чем оставлять её, оцените, окупается ли она. Используйте BenchmarkDotNet и используйте те же переменные среды, которые ранее отображались для сравнения скалярных Vector128и Vector256 реализаций в одном запуске. Диагностический модуль дизассемблирования BenchmarkDotNet также может выводить сгенерированный ассемблерный код, что бесценно при оптимизации высокопроизводительного кода.
Рекомендации:
- Большие объёмы входных данных дают больший выигрыш. Для небольших буферов векторизованный код может быть медленнее скалярного кода из-за затрат на настройку. Проверьте размер входных данных, используемых вызывающими абонентами.
- Ускорение редко бывает идеальным. 256-битный вектор, работающий с 32-битными элементами, не обязательно будет в 8 раз быстрее; важны пропускная способность памяти, выравнивание и задержка инструкций.
- Выравнивание памяти влияет на стабильность. Выравнивание случайного распределения добавляет шум между выполнениями. Вы можете выделить выровненную память с помощью AlignedAlloc для получения стабильных результатов или включить рандомизацию памяти в BenchmarkDotNet, чтобы наблюдать полное распределение.
Лучшие практики
- Сначала обратитесь к существующим API более высокого уровня.
Span<T>,string, LINQ,TensorPrimitivesи тензорные типы уже ускоряют многие распространённые операции — не реализуйте вручную то, что уже оптимизировано и проверено. - Начните с
Vector128<T>; он поддерживает аппаратное ускорение на самом широком спектре оборудования, и вам не нуженVector256<T>, чтобы получить корректную, переносимую реализацию. Добавьте более широкие ширины и встроенные аппаратные компоненты только для измеренных горячих путей. - Проверьте
IsHardwareAcceleratedиCountнапрямую вместо кэширования, JIT превращает их в константы. - Всегда обрабатывайте остаток цикла и учитывайте перекрытие исходного и целевого буферов при записи.
- Сначала напишите тесты для граничных случаев, затем — скалярное решение, а потом выразите эту скалярную логику с помощью векторных API.
- Протестируйте все ветви выполнения кода (включая нарушения доступа) и проведите тесты производительности на реалистичных объёмах входных данных, прежде чем добавлять эту сложность.