.NET Core: интринсики x86_64 на виртуальных машинах
Мы живём в эпоху доминирования архитектуры x86. Все x86-совместимые процессоры похожи, но и все при этом немного отличаются. И не только производителем, частотой и количеством ядер.
Архитектура x86 за время своего существования (и популярности) пережила много крупных апдейтов (например, расширение до 64 бит — x86_64) и добавлений «расширенных наборов инструкций». К этому приходится подстраиваться и компиляторам, которые по-умолчанию генерируют максимально общий для всех процессоров код. Но среди расширенных инструкций есть много интересного и полезного. Например, в шахматных программах часто используются инструкции для работы с битами: POPCNT, BSF/BSR (или более свежие аналоги TZCNT/LZCNT), PDEP, BSWAP и т.д.
В компиляторах C и C++ явный доступ к таким инструкциям реализован через «присущие (intrinsic) данному процессору функции». пример1 пример2
Для .NET и C# такого удобного доступа не существовало, поэтому когда-то давно я сделал свою обёртку, которая предоставляла эмуляцию таких функций, но если CPU их поддерживал, то заменяла их вызов прямо в вызывающем коде. Благо, большинство нужных мне интринсиков помещались в 5 байт опкода CALL. Подробности можно почитать на хабре по этой ссылке.
С тех пор прошло много лет, в .NET нормальных интринсиков так и не появилось. Но вышел .NET Core, в котором ситуацию исправили. Сначала появились векторные инструкции, в потом и почти весь* набор System.Runtime.Intrinsics.X86.
* — нет «устаревших» BSF и BSR
И всё вроде-бы стало хорошо и удобно. Если не считать того, что определение поддержки каждого набора инструкций всегда было запутанным (какие-то включаются сразу наборами, для каких-то есть отдельные флаги). Так .NET Core запутало нас ещё сильнее с тем, что между «разрешёнными» наборами есть ещё и какие-то зависимости.
Всплыло это при попытке запустить код на виртуальной машине с гипервизором KVM: посыпались ошибки System.PlatformNotSupportedException: Operation is not supported on this platform at System.Runtime.Intrinsics.X86.Bmi1.X64.TrailingZeroCount(UInt64 value)
. Аналогично и для System.Runtime.Intrinsics.X86.Popcnt.X64.PopCount. Но если для POPCNT можно было поставить достаточно очевидный флаг в параметрах виртуализации, то TZCNT ввёл меня в тупик. На следующей картинке вывод тулзы, проверяющей доступность интринсиков в netcore (код и бинарник в конце статьи) и всем известного CPU-Z:
А вот вывод тулзы, взятой со страницы MSDN про CPUID:
Несмотря на то, что процессор рапортует о поддержке всего, что требуется, инструкция Intrinsics.X86.Bmi1.X64.TrailingZeroCount
всё равно продолжала падать с эксепшеном System.PlatformNotSupportedException
.
Чтобы в этом разобраться, нам надо взглянуть на процессор глазами NETCore. Исходники которого лежат на гитхабе. Поищем там cupid и выйдем на метод EEJitManager::SetCpuInfo()
В нём достаточно много разных условий, причём некоторые из них вложенные. Я взял этот метод и скопипастил в пустой проект. Дополнительно к нему пришлось забрать пару других методов и ещё целый ассемблерный файл (как добавить асм в свежую студию). Результат выполнения:
Как видим, флаг InstructionSet_BMI1
всё же выставлен (хотя не выставлены некоторые другие).
Если поискать этот флаг по репозиторию, то можно наткнуться на такой код:
if (resultflags.HasInstructionSet(InstructionSet_BMI1) && !resultflags.HasInstructionSet(InstructionSet_AVX))
resultflags.RemoveInstructionSet(InstructionSet_BMI1);
Так вот, она наша зависимость! Если не определился AVX, то отключается и BMI1 (и некоторые другие наборы). В чём логика, мне пока не ясно, но будем надеяться на то, что она всё-таки есть. Теперь осталось разобраться, почему cpu-z и другие тулзы видят AVX, а netcore — нет.
Посмотрим, чем отличается вывод нашей тулзы на разных процессорах:
>diff a b
7c7,8
< Test ((buffer[8] & 0x02) != 0) -> 0
---
> Test ((buffer[8] & 0x02) != 0) -> 1
> ==> Set InstructionSet_PCLMULQDQ
18c19,32
< Test ((buffer[11] & 0x18) == 0x18) -> 0
---
> Test ((buffer[11] & 0x18) == 0x18) -> 1
> Test (hMod == NULL) -> 0
> Test (pfnGetEnabledXStateFeatures == NULL) -> 0
> Test ((FeatureMask & XSTATE_MASK_AVX) == 0) -> 0
> Test (DoesOSSupportAVX() && (xmmYmmStateSupport() == 1)) -> 1
> Test (hMod == NULL) -> 0
> Test (pfnGetEnabledXStateFeatures == NULL) -> 0
> Test ((FeatureMask & XSTATE_MASK_AVX) == 0) -> 0
> ==> Set InstructionSet_AVX
> Test ((buffer[9] & 0x10) != 0) -> 1
> ==> Set InstructionSet_FMA
> Test (maxCpuId >= 0x07) -> 1
> Test ((buffer[4] & 0x20) != 0) -> 1
> ==> Set InstructionSet_AVX2
- Фейлится проверка buffer[8] & 0×02, это PCLMULQDQ
- Фейлится buffer[11] & 0×18, это AVX & OSXSAVE, AVX уже выставлен (это видит CPU-Z), нужен OSXSAVE
- А за ней и другие проверки, которые ведут к флагу InstructionSet_AVX
Так что же делать с вируалкой? Если есть возможность, то лучше всего поставить libvirt.cpu_mode в host-passthrough или host-model.
Но если такой возможности нет, то придётся добавлять весь суп из инструкций, в частности ssse3, sse4.1, sse4.2, sse4a, popcnt, abm, bmi1, bmi2, avx, avx2, osxsave, xsave, pclmulqdq
. Здесь я передаю привет и спасибо vdsina_m;)
А проверить ваш хост или виртуалку на поддержку инструкций и то, как на это смотрит .NET Core можно с помощью этой тулзы: (пока что зип, позже выложу на гитхаб).