.NET SIMD 및 하드웨어 내장 함수 사용

SIMD(단일 명령, 여러 데이터)는 단일 명령으로 여러 데이터에 하나의 작업을 병렬로 적용하기 위한 하드웨어 지원입니다. 벡터화된 코드는 반복당 여러 값을 처리하므로 버퍼에서 동일한 작업이 반복되는 숫자, 과학, 그래픽, 텍스트 처리 및 데이터 병렬 작업의 종류에 대한 처리량을 크게 늘릴 수 있습니다. 그 대가로 복잡성이 늘어나므로, 입력 규모가 충분히 크고 측정을 통해 성능 향상이 확인될 때 가장 큰 효과를 발휘합니다.

.NET 여러 종류의 SIMD 지원을 제공합니다. 필요한 컨트롤의 양과 원하는 복잡성과 일치하는 컨트롤을 선택합니다.

.NET SIMD 지원 유형

응용 프로그램 인터페이스 (API) 네임스페이스 사용 시기
고정 용도 벡터 및 행렬 형식 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 Span에 대한 즉시 사용 가능한 벡터화 수학 연산 그것은 당신을 위해 벡터화를 수행합니다.

이러한 API가 겹치는 경우 추상화 계층을 통해 관련됩니다. 제네릭 벡터 형식은 다른 레이어가 전달하는 기본 교환 형식이므로 기술적으로 가장 낮은 수준입니다. 즉, 실행 중인 하드웨어에서 지원하는 너비로 증가하는 가변 너비 Vector<T>와 고정 너비 Vector64<T>Vector512<T>까지 증가합니다. 플랫폼별 하드웨어 내장 System.Runtime.Intrinsics.X86System.Runtime.Intrinsics.Arm함수는 개별 프로세서 명령에 직접 매핑되며 System.Runtime.Intrinsics.Wasm 이러한 형식에서 작동합니다. 제네릭 형식에 노출되는 플랫폼 간 작업은 플랫폼별 내장 함수보다 한 단계 위에 있으며 각 대상에 대해 이를 낮춥니다. 그보다 더 상위에는 전체 버퍼를 대상으로 작동하는 관리형 API(Span<T>string의 벡터화된 메서드와 TensorPrimitives)가 있으며, 이들은 아래 계층을 기반으로 구축되어 이를 일일이 직접 작성하지 않고도 SIMD 가속을 활용할 수 있습니다. 고정 셰이프 형식은 System.Numerics 이 교환 스택의 일부가 아닌 그래픽 및 기하 도형에 대한 도메인별 편리한 형식입니다.

이 문서의 나머지 부분에는 최상위 수준에서 가장 낮은 수준까지 이러한 API를 통해 작업한 다음 테스트, 벤치마킹 및 모범 사례를 다룹니다.

System.Numerics 벡터 및 행렬 형식

System.Numerics 네임스페이스는 고정된 형태의 SIMD 가속을 지원하는 형식을 제공합니다:

이러한 형식은 그래픽 및 기하 도형에 자연스럽게 매핑되며 런타임은 하드웨어에서 지원하는 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 은 코드를 실행하는 CPU에 따라 달라집니다. JIT(Just-In-Time) 컴파일러는 상수로 처리 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 이와 같은 루프를 직접 작성할 필요가 거의 없습니다. 이 요소별 덧셈은 Add이며, Sum와 같은 리덕션 연산도 제공됩니다. 이러한 작업은 Vector<T>가 지원하는 요소 형식(Vector<T>.IsSupported)에 대해 하드웨어 가속이 지원됩니다.

하드웨어 가속 확인

SIMD 가속 타입은 SIMD를 지원하지 않는 하드웨어나 JIT 구성에서도 작동하는데, 이는 가속을 사용하지 않는 소프트웨어 구현으로 폴백하기 때문입니다. 가속을 실제로 사용할 수 있는지 여부를 분기하려면 관련 IsHardwareAccelerated 속성을 확인합니다.

이러한 속성은 JIT에 의해 상수로 바뀌므로 사용하지 않는 분기가 제거되고 이를 확인하는 데 런타임 비용이 없습니다. 값을 캐시하지 마세요. 필요한 위치에서 직접 읽어 읽습니다. JIT 시간 상수인 속성(예: Count)에도 동일하게 적용됩니다Vector128<T>.Count.

가속 너비에 대한 대부분의 작업은 자체 가속되지만 모든 작업에 대해 보장되지는 않습니다. 예를 들어 정수 구분이 아닌 경우 부동 소수점 나누기를 가속화할 수 있습니다. Vector256이 가속되면 Vector128도 대개 함께 가속되지만, 보장되지는 않으므로 사용하는 각 너비를 확인하세요.

팁 (조언)

필요한 작업이 관심 있는 플랫폼에서 가속화되지 않거나 새 플랫폼 간 API를 원하는 경우 dotnet/runtime에 문제를 제출합니다. codegen 개선 사항도 마찬가지입니다.

모든 요소 형식이 모든 벡터에 대해 유효한 것은 아닙니다. Vector128<T>및 해당 형제는 오늘날 기본 숫자 형식(byte, ,sbyte, short, ushort, int, uintlong, ulongfloatdoublenintnuint)을 지원하며, 해당 집합은 나중에 다른 형식을 포함하도록 증가할 수 있습니다. 지정된 T가 유효한지 여부를 확인하려면 Vector128<T>.IsSupported를 사용하며, 이는 특히 제네릭 코드에서 유용합니다.

지원되지 않는 형식(예: charbool)은 버퍼를 동일한 크기의 지원되는 형식으로 재해석하여 벡터화할 수 있습니다. span을 재해석하려면 Cast를 사용하세요. 예를 들어 char에서 ushort로 재해석할 때 사용합니다. 또는 이미 보유한 벡터를 재해석하려면 벡터의 As<TFrom, TTo> 메서드를 사용하세요. 재해석은 기저 비트는 바꾸지 않고 형식만 바꾸므로, 데이터를 올바른 형식으로 유지하는 것은 사용자의 책임입니다. 즉, bool는 반드시 0 또는 1인 상태를 유지해야 하며, char는 유효한 UTF-16 코드 단위로 남아 있어야 합니다. 벡터화된 연산에서 범위를 벗어난 값을 생성할 수 있는 경우 결과를 다시 쓰기 전에 정규화합니다.

Vector128을 사용하여 플랫폼 간 벡터화

Vector128<T> 는 벡터화를 지원하는 모든 플랫폼에서 공통 분모이므로 시작하는 것이 가장 좋습니다. 이는 128비트 벡터를 저장합니다. 즉, 16바이트, 8개의 short형 정수, 4개의 int형 정수 또는 float형, 혹은 2개의 long형 정수 또는 double형을 담습니다.

------------------------------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>)과 CreateLoad 같은 정적 팩터리 메서드를 포함해 대부분의 작업을 제공하는 비제네릭 정적 클래스(Vector128)가 있습니다. 연산자와 같은 +&<< 연산자는 산술 및 비트 연산을 표현하는 idiomatic 방법입니다. 연산자 우선 순위 버그를 방지하고 가독성을 향상시키기 위해 명명된 메서드에 해당하는 연산자보다 선호합니다. 바이트 순서에 따라 달라지는 알고리즘의 경우 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에서는 HorizontalAdd를 두 번 호출하면 Vector128<float>(4개 요소)에 도달할 수 있습니다:

// 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)는 상위 레인 전체에 브로드캐스트되며, 8개 요소 전체의 합계가 되는 것은 아닙니다:

// 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로 각 레인의 부분 합계를 읽어 모두 더하세요. GetLowerGetUpper는 벡터를 앞쪽 절반과 뒤쪽 절반으로 나눕니다:

------------------------------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 범위를 노출합니다. 범주를 알고 필요할 때 세부 정보를 조회하여 기억하지 않아도 됩니다. 모든 작업은 해당 작업을 가속할 수 없는 플랫폼에서도 사용할 수 있는 소프트웨어 대체 수단을 갖추고 있습니다. 다음 표에서는 기본적으로 전체 표면을 다룹니다.

카테고리 용도 대표적인 API
Constants 미리 정의된 상수 벡터 Zero, One, NegativeOne, AllBitsSet, Indices, SignSequence, E, Pi, Tau, Epsilon, NaN, PositiveInfinity, NegativeInfinity,NegativeZero
Creation 스칼라를 브로드캐스트하고, 요소를 설정하거나 시퀀스를 생성합니다 Create, CreateScalar, CreateScalarUnsafe, Create(ReadOnlySpan<T>), CreateSequence, CreateGeometricSequence, CreateHarmonicSequenceCreateAlternatingSequence
로드 및 저장 메모리와 벡터 간에 데이터 이동 Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAlignedStoreAlignedNonTemporal, CopyToTryCopyTo
산술 요소별 수학 및 감소 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,Max, Clamp, MinMagnitude, MaxMagnitude, MinNumberMaxNumber, MinMagnitudeNumberMaxMagnitudeNumber
반올림 각 요소를 정수 값으로 반올림 Ceiling, Floor, , Round, Truncate
수식 함수 기호, 보간, 각도 및 초월 도우미 CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2
Comparison 각 요소를 비교합니다. 결과는 연산자가 제공하는 것이 아니라 bool 벡터 마스크입니다. Equals, GreaterThan, GreaterThanOrEqual, LessThanLessThanOrEqual
Classification 요소별 조건자는 각각 벡터 마스크를 반환하는 숫자 줄에 대한 조건자입니다. IsNaN, IsFinite,IsInfinity, IsPositiveInfinity, IsNegativeInfinity, IsInteger, IsEvenInteger, IsOddInteger, IsNegative, IsPositive, IsNormal, IsSubnormal, IsZero
비교 감소 요소별 비교를 단일 값으로 축소 bool EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAllLessThanAny, LessThanOrEqualAllLessThanOrEqualAny
전체 벡터 조건자 벡터를 bool로 축약합니다. 즉, 모든 요소, 일부 요소 또는 어떤 요소도 특정 값과 같은지 여부, 또는(WhereAllBitsSet 형식의 경우) 모든 비트가 설정되어 있는지 여부를 확인합니다. 마스크를 인덱스로 변환하는 대신 이러한 항목을 사용하는 것이 좋습니다. All, Any, None, AllWhereAllBitsSet, AnyWhereAllBitsSetNoneWhereAllBitsSet
Search 값으로 요소를 세거나 찾거나, 또는(WhereAllBitsSet 형식의 경우) 마스크에서 레인을 설정 Count, IndexOf, LastIndexOf, CountWhereAllBitsSet, IndexOfWhereAllBitsSetLastIndexOfWhereAllBitsSet
마스크를 인덱스로 비교 마스크를 스칼라 비트 마스크로 전환하고 스캔합니다. ExtractMostSignificantBitsTrailingZeroCount 또는 LeadingZeroCount
Selection 마스크에 따라 두 개의 벡터를 비트씩 혼합합니다. ConditionalSelect(x, y, z), (y & x) \| (z & ~x)에 해당
Conversion 숫자 형식을 변경하고 새 값을 계산합니다(예: intfloat). ConvertToInt32, ConvertToInt64, ConvertToUInt32, ConvertToUInt64, ConvertToSingleConvertToDouble
확대 및 축소 요소를 더 넓은 형식으로 분할하거나 더 좁은 형식으로 압축 Widen, WidenLower, WidenUpper, NarrowNarrowWithSaturation
재해석 비트를 변경하지 않고 다른 요소 형식으로 재해석 As<TFrom, TTo>, AsByte, AsInt32AsSingle및 기타 As* 요소 양식
System.Numerics interop Vector128<T>와 고정 형태 숫자 형식 간 재해석 AsVector, AsVector2, AsVector3, AsVector4, AsPlane, AsQuaternion, AsVector128AsVector128Unsafe
Reorder 인덱스별로 요소 재배열 또는 두 벡터 교차 배치 Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper
차선 접근 개별 요소와 반쪽을 읽거나 바꾸거나 벡터 크기를 조정합니다. GetElement, WithElement, ToScalar, GetLower, GetUpper, WithLower, WithUpperToVector256

팁 (조언)

여러 작업에는 EstimateNative 변형이 있습니다. 예를 들어 MultiplyAddEstimate, ClampNative, MinNative, MaxNative, ShuffleNative, ConvertToInt32Native 등이 있습니다. 이들은 정밀도를 일부 희생하거나 IEEE의 예외적인 경우에 대한 보장(예: NaN 처리)을 포기하는 대신 더 빠른 하드웨어 명령으로 매핑되므로, 벤치마크 결과 해당 형식이 실제 병목이고 더 완화된 의미 규칙을 수용할 수 있는 경우에만 사용해야 합니다.

비고

Vector256.Shuffle 는 입력을 단일 256비트 벡터로 처리합니다. 여기서 플랫폼별 Avx2.Shuffle 벡터는 두 개의 독립적인 128비트 레인으로 작동합니다. 플랫폼 간 API는 이식 가능한 선택이지만 손으로 쓴 내장 함수를 포팅할 때 필요한 동작을 확인합니다.

코드 경로 구조화

벡터화된 메서드는 일반적으로 각 벡터 너비별 경로와, 입력이 작거나 하드웨어 가속이 없는 경우를 위한 스칼라 폴백 경로로 분기됩니다. 하드웨어에서 지원하는 가장 큰 벡터를 사용하려면 먼저 가장 넓은 벡터를 확인하고 아래로 작업합니다.

// 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에 대해 제네릭으로 정의되며, Vector256Vector512 블록은 더 넓은 타입을 사용한다는 점만 제외하면 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 경로에 대한 더 큰 폭의 나머지 처리가 점프 테이블 자체에 직접 들어 있습니다. 길이가 너비의 정확한 배수가 아닐 때마다 두 로드가 겹치므로 합계를 계산하기 전에 꼬리가 가산 ID로 ConditionalSelect 마스킹됩니다. 그 마스크는 덧셈이 멱등적이지 않기 때문에만 필요합니다. 검색과 같은 멱등 연산은 겹치는 끝부분을 직접 합쳐 처리할 수 있습니다. 벡터화가 전혀 없는 하드웨어의 버퍼는 일반 스칼라 루프를 통과 SumScalar합니다.

입력을 반복하고 나머지를 처리합니다.

단일 벡터보다 큰 버퍼를 처리하려면 버퍼를 한 번에 벡터 하나씩 순회하면서 처리한 다음, 완전한 벡터를 채우지 못하는 남은 요소를 처리합니다. 그 남은 부분을 처리하는 확실한 방법은 마지막 전체 벡터 1개 분량의 요소를 다시 처리하되, 루프가 이미 처리한 일부 요소와 겹치게 하는 것입니다. 이렇게 하면 별도의 스칼라 에필로그가 필요 없어집니다. 겹침 시 수정이 필요한지 여부는 작업에 따라 달라집니다.

합계와 같은 멱등성이 아닌 연산은 겹치는 요소를 두 번 계산하므로 접기 전에 작업의 ID로 마스킹합니다. 각 요소가 정확히 한 번 기여해야 하는 경우 이를 사용합니다.

// 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로 감싸서 작은 페이로드의 경우 4개의 누산기를 완전히 건너뛰고 곧바로 나머지 처리로 넘어갑니다. 데이터가 충분할 때, do/while는 각 반복에서 4개의 벡터를 서로 독립적인 누적기에 누적하는데, 이렇게 하면 프로세서가 덧셈 연산을 파이프라인화할 수 있고 그 결과를 쌍별로 결합합니다. 그런 다음 점프 테이블은 switch 남아 있는 0~3개의 전체 벡터를 반영하고, case 0에서는 서브벡터 테일까지 처리합니다. 이를 위해 버퍼 끝에서 미리 로드한 전체 벡터를 재사용하되, 이미 처리된 요소와 겹치는 부분은 ConditionalSelect를 사용해 덧셈 항등원이 되도록 마스킹하여, 테일이 스칼라 루프로 빠지지 않고 계속 벡터화된 상태를 유지하도록 합니다. 이전과 마찬가지로 Vector128.Create는 스팬에서 Vector128<T>.Count 요소를 읽습니다. JIT는 일반적인 액세스 패턴에 대한 범위 검사를 생략하므로 Create 핫 루프 LoadUnsafe 에서도 적절한 기본값입니다. (다음에 설명됨) 관리되는 참조로 버퍼를 걸을 때의 하위 수준 대안입니다. 매우 큰 입력의 경우, 완전한 구현이라면 버퍼도 정렬하고 유용한 캐시 라인을 축출하지 않도록 non-temporal 로드/저장도 사용하겠지만, 이 둘은 여기서는 생략되었으며 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;
}

Warning

나머지를 잘못 처리하면 버그의 일반적인 원인입니다. 버퍼의 끝을 지나서 읽는 루프는 비결정적 결과를 생성하고 충돌할 수 있습니다. 런타임의 테스트 스위트는 버퍼 바로 뒤에 접근할 수 없는 페이지를 배치하는 BoundedMemory 도우미를 사용하므로, 테스트 중 범위를 벗어난 읽기가 발생하면 AccessViolationException가 발생합니다. 길이가 벡터 너비의 배수가 아닌 버퍼까지 포함해 항상 나머지 처리 로직을 다뤄야 합니다.

안전하게 벡터 로드 및 저장

대부분의 코드에서는 Vector128.Create(span)CopyTo가 span과 vector 사이에서 데이터를 이동하는 가장 간단한 방법이며, JIT는 이들이 효율적으로 작동하도록 유지합니다. 예를 들어 관리 참조로 버퍼를 순회하기 위해 더 낮은 수준의 로드 및 저장이 필요한 경우, 관리 참조와 nuint 요소 오프셋을 사용하는 LoadUnsafeStoreUnsafe 오버로드를 사용하는 것이 좋습니다. 포인터 기반 Load/Store 오버로드와 달리 버퍼를 고정할 필요가 없고, 원시 참조 산술과 달리 ref를 수동으로 증가시킬 필요도 없습니다. 두 가지 대안 모두 가비지 수집기 구멍 또는 액세스 위반을 도입하는 방식으로 잘못되기 쉽습니다.

빈 버퍼가 예외를 발생시키지 않도록 하려면 시작 참조는 ref span[0]가 아니라 GetReference(배열의 경우 GetArrayDataReference)에서 가져오세요.

중요합니다

오프셋 산술 연산은 부호 없는 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)으로 낮아지므로 복잡성 없이 손으로 쓴 분기와 동일한 작업을 수행합니다. 특정 명령이 이식 가능한 API가 생성하는 항목을 측정적으로 능가하는 경우에만 명시적 내장 함수에 도달합니다.

하드웨어 내장 함수는 명령 집합당 별도의 구현이 필요하므로 기본값이 아닌 측정된 핫 경로에 대한 최적화로 처리합니다. API는 Vector128/Vector256 이미 각 플랫폼에서 효율적인 지침보다 낮으며 실제로는 정교한 명령별 코드가 항상 승리하지는 않습니다. 추가 유지 관리를 커밋하기 전에 벤치마크와의 차이점을 확인합니다.

TensorPrimitives를 사용한 상위 수준 수학

Span에서 벡터화된 수학 연산이 필요하지만 루프를 직접 작성하고 싶지 않다면, TensorPrimitives는 요소별 산술 연산, 지수 연산, 그리고 내적 및 코사인 유사도와 같은 리덕션 연산을 포함하는 다양한 수치 연산 집합을 제공합니다. 이러한 연산은 내부적으로 이미 벡터화되어 있습니다. System.Numerics.Tensors NuGet 패키지에서 사용할 수 있습니다.

// 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);

AI 및 숫자 워크로드 TensorPrimitives 의 경우 복잡성이 전혀 없는 손으로 작성한 SIMD의 이점을 대부분 제공하는 경우가 많습니다.

모든 코드 경로 테스트

벡터화된 메서드에는 여러 코드 경로가 있으므로 테스트는 Vector256 경로, Vector128 경로, 그리고 스칼라 경로를 각각 모두 다뤄야 하며, 각 경로에 대해 벡터화의 이점을 얻을 만큼 충분히 큰 입력과 그 이점을 얻기에는 너무 작은 입력을 모두 포함해야 합니다. 테스트에서 입력 크기를 변경할 수 있지만 테스트 수준에서 하드웨어 가속을 전환할 수는 없습니다. 대신 프로세스가 시작되기 전에 환경 변수를 사용하여 제어합니다.

  • Vector256.IsHardwareAcceleratedfalse를 반환하도록 DOTNET_EnableAVX2=0을 설정합니다.
  • DOTNET_EnableHWIntrinsic=0를 설정하여 내장 함수를 완전히 비활성화하면 Vector128, Vector64, Vector<T>가 모두 가속 없음으로 보고됩니다.

단일 머신에서 모든 경로를 실행해 보려면 테스트 스위트를 재정의 없이 한 번, DOTNET_EnableAVX2=0로 한 번, 그리고 DOTNET_EnableHWIntrinsic=0로 한 번 실행하세요. 대안은 이를 커버하기에 충분한 다양한 하드웨어에서 실행됩니다.

명령어 집합 설정 옵션

이 두 가지 외에도 런타임은 명령어 집합의 각 논리적 그룹마다 하나의 옵션을 인식하며, 각 옵션에는 DOTNET_ 접두사가 붙습니다. 단일 노브는 BMI1, BMI2, F16C, FMA, LZCNT 및 MOVBE와 함께 AVX2 게이트와 같은 여러 관련 명령 집합EnableAVX2을 포함할 수 있습니다. 노브를 0로 설정하면 해당 전체 그룹과 그 위에 레이어된 모든 항목이 비활성화됩니다. 이를 1(대부분의 경우 기본값)으로 설정하면 해당 그룹이 허용되지만, 하드웨어가 실제로 이를 지원해야 합니다. 현재 CPU가 지원하지 않는 옵션을 켜는 것은 무시되므로, 실제로 사용되는 범위만 줄일 수 있을 뿐 지원되지 않는 명령을 억지로 활성화해 시스템에 문제를 일으킬 수는 없습니다. DOTNET_EnableHWIntrinsic=0는 강력한 수단으로, 기반 수준까지 모든 것을 비활성화하므로 Vector128, Vector64, Vector<T>가 모두 가속이 없다고 보고하고 코드는 소프트웨어 경로로 전환됩니다.

중요합니다

이러한 도구는 주로 테스트 및 유효성 검사를 위한 진단 도구로, 각 코드 경로를 실행하거나, 하드웨어 관련 문제를 재현하거나, 대체를 확인합니다. 일반 또는 프로덕션용으로 설계되지 않았으며 안정성 계약이 아닙니다. 다음 집합은 .NET 11이 인식하는 것입니다. 이전 릴리스에서는 다른 집합(특히 기준 및 AVX-512 노브가 다시 구성됨)을 노출했으므로 대상으로 하는 런타임 버전에 대한 이름을 확인합니다.

이러한 도구에는 도달 범위도 제한됩니다. JIT 결정을 제어하기 때문에 이미 ReadyToRun 또는 Native AOT를 통해 미리 컴파일된 코드에는 영향을 주지 않으며 런타임 및 핵심 라이브러리가 자체적으로 사용하는 내부 루틴에 반드시 영향을 주지는 않습니다. 이를 명령 집합 전체를 끄는 전역 스위치가 아니라, 자체 JIT 컴파일 코드의 동작을 제어하는 수단으로 여기세요.

기본 스위치와 너비 한도는 모든 아키텍처에 적용됩니다.

노브(DOTNET_ 접두어) Default Effect
EnableHWIntrinsic 1 모든 하드웨어 내장 함수에 대한 마스터 스위치; 0 는 전체 소프트웨어 경로를 강제합니다.
MaxVectorTBitWidth 시스템 기본값 Vector<T>를 비트 단위의 최대 너비로 제한합니다. 128보다 작은 값은 시스템 기본값을 의미합니다.
PreferredVectorBitWidth 시스템 기본값 보고 IsHardwareAccelerated하는 최대 고정 너비 벡터의 상한을 비트 단위로 지정합니다. 128 미만의 값은 시스템 기본값을 의미합니다.

시스템 기본값 MaxVectorTBitWidth 은 하드웨어가 완전히 지원하는 것보다 좁을 수 있으므로 Vector<T> 사용 가능한 가장 넓은 벡터로 자동 증가하지 않습니다. 예를 들어, Vector512<T>.IsHardwareAcceleratedtrue일 수 있지만 Vector<T>는 256비트로 유지됩니다. 더 넓은 너비를 사용하도록 DOTNET_MaxVectorTBitWidth=512을 설정하여 Vector<T>에 적용할 수 있습니다.

PreferredVectorBitWidthIsHardwareAccelerated에 보고되는 최대 벡터 너비를 제한합니다. 하드웨어가 지원하는 수준보다 그것을 낮게 설정하면 더 넓은 너비가 비활성화됩니다. 512비트 벡터를 지원하는 시스템에서는 DOTNET_PreferredVectorBitWidth=256을 사용하면 Vector512<T>.IsHardwareAcceleratedfalse를 보고합니다. 일반적인 노브이지만 x86/x64만이 오늘 128보다 높은 너비를 제공하므로 관찰 가능한 효과가 있는 유일한 장소입니다.

x86/x64 명령 집합의 각 논리 그룹화에는 자체 스위치도 있습니다.

노브 (DOTNET_ 접두사) Default 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 (Secure Hash Algorithm)
EnableVAES 1 VAES, VPCLMULQDQ
EnableWAITPKG 1 WAITPKG
EnableX86Serialize 1 X86 SERIALIZE

Arm64에서 명령 집합의 각 논리적 그룹화에는 고유한 스위치가 있습니다.

노브 (DOTNET_ 접두사) Default Gates
EnableArm64Aes 1 고급 암호화 표준 (AES)
EnableArm64Atomics 1 LSE(대규모 시스템 확장) 원자적 연산
EnableArm64Crc32 1 CRC32
EnableArm64Dczva 1 DC ZVA 캐시 제로화
EnableArm64Dp 1 점 제품
EnableArm64Rdm 1 반올림 두 배 곱하기 누적(RDM)
EnableArm64Sha1 1 SHA1
EnableArm64Sha256 1 SHA256
EnableArm64Rcpc 1 릴리스 일관성, 프로세서 일치 순서 지정(RCpc)
EnableArm64Rcpc2 1 RCpc2
EnableArm64Cssc 0 CSSC(Common Short Sequence Compression)
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을 사용하고 이전에 표시된 것과 동일한 환경 변수를 사용하여 스칼라Vector128Vector256 구현을 한 번의 실행으로 비교합니다. BenchmarkDotNet의 디스어셈블리 진단자는 생성된 어셈블리를 내보낼 수도 있습니다. 이 어셈블리는 고성능 코드를 튜닝할 때 매우 중요합니다.

염두할 점:

  • 입력이 클수록 더 많은 이점이 있습니다. 작은 버퍼의 경우 설치 오버헤드로 인해 벡터화된 코드가 스칼라 코드보다 느려질 수 있습니다. 호출자가 실제로 사용하는 입력 크기를 벤치마킹합니다.
  • 속도 향상은 거의 완벽하지 않습니다. 32비트 요소에서 작동하는 256비트 벡터는 8배 더 빠르지 않습니다. 메모리 처리량, 맞춤 및 명령 대기 시간이 모두 고려됩니다.
  • 메모리 맞춤은 안정성에 영향을 줍니다. 임의 할당 맞춤은 실행 사이에 노이즈를 추가합니다. 안정적인 결과를 위해 정렬된 메모리를 AlignedAlloc 할당하거나 BenchmarkDotNet의 메모리 임의화를 사용하여 전체 분포를 관찰할 수 있습니다.

모범 사례

  • 먼저 기존 상위 수준 API에 도달합니다. Span<T>, string, LINQ, TensorPrimitives, 그리고 텐서 형식은 이미 많은 일반적인 작업을 가속해 주므로, 이미 최적화되고 검증된 기능을 굳이 직접 구현하지 마세요.
  • Vector128<T>로 시작하세요. 가장 폭넓은 하드웨어에서 가속되며, 정확하고 이식성 있는 구현을 얻기 위해 Vector256<T>가 필요하지 않습니다. 측정된 핫 경로에 대해서만 더 넓은 너비 및 하드웨어 내장 함수를 추가합니다.
  • IsHardwareAcceleratedCount를 캐시하지 말고 직접 확인하세요. JIT가 이를 상수로 최적화합니다.
  • 항상 루프 나머지를 처리하고 저장할 때 겹치는 원본 및 대상 버퍼를 고려합니다.
  • 먼저 에지 케이스 테스트를 작성한 다음 스칼라 솔루션을 작성한 다음, 벡터 API를 사용하여 해당 스칼라 논리를 표현합니다.
  • 추가된 복잡성을 커밋하기 전에 모든 코드 경로(액세스 위반 포함)를 테스트하고 실제 입력 크기를 벤치마킹합니다.

참고하십시오