Как работает диспетчер CPU?#

Диспетчер NumPy основан на компиляции из нескольких источников, что означает взятие определенного исходного кода и его компиляцию несколько раз с разными флагами компилятора, а также с разными C определения, которые влияют на пути кода. Это включает определенные наборы инструкций для каждого скомпилированного объекта в зависимости от требуемых оптимизаций и завершается связыванием возвращенных объектов вместе.

../../_images/opt-infra.png

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

1- Конфигурация#

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

  • --cpu-baseline: минимальный набор необходимых оптимизаций.

  • --cpu-dispatch: диспетчеризованный набор дополнительных оптимизаций.

2- Обнаружение окружения#

В этой части мы проверяем компилятор и архитектуру платформы и кэшируем некоторые промежуточные результаты для ускорения пересборки.

3- Проверка запрошенных оптимизаций#

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

4- Генерация основного заголовка конфигурации#

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

Также содержит дополнительные C-определения, которые используются для определения атрибутов модуля NumPy на уровне Python __cpu_baseline__ и __cpu_dispatch__.

Что находится в этом заголовке?

Пример заголовка был динамически сгенерирован gcc на машине X86. Компилятор поддерживает --cpu-baseline="sse sse2 sse3" и --cpu-dispatch="ssse3 sse41", и результат приведен ниже.

// The header should be located at numpy/numpy/_core/src/common/_cpu_dispatch.h
/**NOTE
 ** C definitions prefixed with "NPY_HAVE_" represent
 ** the required optimizations.
 **
 ** C definitions prefixed with 'NPY__CPU_TARGET_' are protected and
 ** shouldn't be used by any NumPy C sources.
 */
/******* baseline features *******/
/** SSE **/
#define NPY_HAVE_SSE 1
#include 
/** SSE2 **/
#define NPY_HAVE_SSE2 1
#include 
/** SSE3 **/
#define NPY_HAVE_SSE3 1
#include 

/******* dispatch-able features *******/
#ifdef NPY__CPU_TARGET_SSSE3
  /** SSSE3 **/
  #define NPY_HAVE_SSSE3 1
  #include 
#endif
#ifdef NPY__CPU_TARGET_SSE41
  /** SSE41 **/
  #define NPY_HAVE_SSE41 1
  #include 
#endif

Базовые возможности являются минимальным набором требуемых оптимизаций, настроенных через --cpu-baselineУ них нет препроцессорных защит, и они всегда включены, что означает, что их можно использовать в любом исходном коде.

Означает ли это, что инфраструктура NumPy передаёт флаги компилятора базовых функций во все исходники?

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

Что если пользователь указывает определенные базовые функции во время сборки, но во время выполнения машина не поддерживает даже эти возможности? Будет ли скомпилированный код вызываться через одно из этих определений, или возможно, сам компилятор автоматически сгенерировал/векторизовал определённый фрагмент кода на основе предоставленных флагов компилятора командной строки?

Во время загрузки модуля NumPy происходит этап проверки, который обнаруживает это поведение. Он вызовет ошибку времени выполнения Python, чтобы уведомить пользователя. Это предотвращает достижение процессором ошибки недопустимой инструкции, вызывающей segfault.

Диспетчеризуемые функции это наш диспетчеризованный набор дополнительных оптимизаций, которые были настроены через --cpu-dispatch. Они не активированы по умолчанию и всегда защищены другими определениями C с префиксом NPY__CPU_TARGET_. C определения NPY__CPU_TARGET_ включены только внутри диспетчеризуемые источники.

5- Диспетчеризуемые источники и операторы конфигурации#

Диспетчеризуемые источники являются специальными C файлы, которые могут быть скомпилированы несколько раз с разными флагами компилятора, а также с разными C определения. Они влияют на пути кода для включения определенных наборов инструкций для каждого скомпилированного объекта в соответствии с "операторы конфигурациикоторое должно быть объявлено между C комментарий(/**/) и начинать со специальной метки @targets в начале каждого диспетчеризуемого исходного кода. В то же время диспетчеризуемые исходные коды будут обрабатываться как обычные C источники, если оптимизация была отключена аргументом командной строки --disable-optimization .

Что такое операторы конфигурации?

Операторы конфигурации — это своего рода ключевые слова, объединённые вместе для определения требуемой оптимизации для диспетчеризуемого источника.

Пример:

/*@targets avx2 avx512f vsx2 vsx3 asimd asimdhp */
// C code

Ключевые слова в основном представляют дополнительные оптимизации, настроенные через --cpu-dispatch, но он также может представлять другие опции, такие как:

  • Целевые группы: предварительно настроенные конфигурационные операторы, используемые для управления требуемыми оптимизациями извне диспетчеризуемого источника.

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

  • “baseline”: уникальное ключевое слово представляет минимальные оптимизации, настроенные через --cpu-baseline

Инфраструктура Numpy обрабатывает диспетчеризуемые источники в четыре шага:

  • (A) Распознавание: Как и исходные шаблоны и F2PY, диспетчеризуемые источники требуют специального расширения *.dispatch.c для пометки C файлов, доступных для диспетчеризации, и для C++ *.dispatch.cpp или *.dispatch.cxx ПРИМЕЧАНИЕ: C++ пока не поддерживается.

  • (B) Разбор и проверка: На этом шаге диспетчеризуемые источники, отфильтрованные предыдущим шагом, анализируются и проверяются операторами конфигурации для каждого из них по одному, чтобы определить требуемые оптимизации.

  • (C) Обёртка: Это подход, принятый инфраструктурой NumPy, который оказался достаточно гибким для компиляции одного источника несколько раз с разными C определениям и флагам, которые влияют на пути выполнения кода. Процесс достигается путем создания временного C источник для каждого требуемого оптимизационного действия, связанного с дополнительной оптимизацией, который содержит объявления C определения и включает исходный код через C директива #include. Для более подробного объяснения посмотрите следующий код для AVX512F :

    /*
     * this definition is used by NumPy utilities as suffixes for the
     * exported symbols
     */
    #define NPY__CPU_TARGET_CURRENT AVX512F
    /*
     * The following definitions enable
     * definitions of the dispatch-able features that are defined within the main
     * configuration header. These are definitions for the implied features.
     */
    #define NPY__CPU_TARGET_SSE
    #define NPY__CPU_TARGET_SSE2
    #define NPY__CPU_TARGET_SSE3
    #define NPY__CPU_TARGET_SSSE3
    #define NPY__CPU_TARGET_SSE41
    #define NPY__CPU_TARGET_POPCNT
    #define NPY__CPU_TARGET_SSE42
    #define NPY__CPU_TARGET_AVX
    #define NPY__CPU_TARGET_F16C
    #define NPY__CPU_TARGET_FMA3
    #define NPY__CPU_TARGET_AVX2
    #define NPY__CPU_TARGET_AVX512F
    // our dispatch-able source
    #include "/the/absolute/path/of/hello.dispatch.c"
    
  • (D) Заголовок конфигурации для диспетчеризации: Инфраструктура генерирует заголовок конфигурации для каждого диспетчеризуемого источника, этот заголовок в основном содержит два абстрактных C макросы, используемые для идентификации сгенерированных объектов, чтобы их можно было использовать для динамической диспетчеризации определенных символов из сгенерированных объектов любым C источник. Также используется для прямых объявлений.

    Сгенерированный заголовок принимает имя диспетчеризуемого исходного файла после исключения расширения и заменяет его на .h, например, предположим, у нас есть вызываемый источник с именем hello.dispatch.c и содержит следующее:

    // hello.dispatch.c
    /*@targets baseline sse42 avx512f */
    #include 
    #include "numpy/utils.h" // NPY_CAT, NPY_TOSTR
    
    #ifndef NPY__CPU_TARGET_CURRENT
      // wrapping the dispatch-able source only happens to the additional optimizations
      // but if the keyword 'baseline' provided within the configuration statements,
      // the infrastructure will add extra compiling for the dispatch-able source by
      // passing it as-is to the compiler without any changes.
      #define CURRENT_TARGET(X) X
      #define NPY__CPU_TARGET_CURRENT baseline // for printing only
    #else
      // since we reach to this point, that's mean we're dealing with
        // the additional optimizations, so it could be SSE42 or AVX512F
      #define CURRENT_TARGET(X) NPY_CAT(NPY_CAT(X, _), NPY__CPU_TARGET_CURRENT)
    #endif
    // Macro 'CURRENT_TARGET' adding the current target as suffix to the exported symbols,
    // to avoid linking duplications, NumPy already has a macro called
    // 'NPY_CPU_DISPATCH_CURFX' similar to it, located at
    // numpy/numpy/_core/src/common/npy_cpu_dispatch.h
    // NOTE: we tend to not adding suffixes to the baseline exported symbols
    void CURRENT_TARGET(simd_whoami)(const char *extra_info)
    {
        printf("I'm " NPY_TOSTR(NPY__CPU_TARGET_CURRENT) ", %s\n", extra_info);
    }
    

    Теперь предположим, что вы присоединили hello.dispatch.c в исходное дерево, тогда инфраструктура должна сгенерировать временный заголовочный файл конфигурации с именем hello.dispatch.h доступный из любого источника в дереве исходного кода, и он должен содержать следующий код:

    #ifndef NPY__CPU_DISPATCH_EXPAND_
      // To expand the macro calls in this header
        #define NPY__CPU_DISPATCH_EXPAND_(X) X
    #endif
    // Undefining the following macros, due to the possibility of including config headers
    // multiple times within the same source and since each config header represents
    // different required optimizations according to the specified configuration
    // statements in the dispatch-able source that derived from it.
    #undef NPY__CPU_DISPATCH_BASELINE_CALL
    #undef NPY__CPU_DISPATCH_CALL
    // nothing strange here, just a normal preprocessor callback
    // enabled only if 'baseline' specified within the configuration statements
    #define NPY__CPU_DISPATCH_BASELINE_CALL(CB, ...) \
      NPY__CPU_DISPATCH_EXPAND_(CB(__VA_ARGS__))
    // 'NPY__CPU_DISPATCH_CALL' is an abstract macro is used for dispatching
    // the required optimizations that specified within the configuration statements.
    //
    // @param CHK, Expected a macro that can be used to detect CPU features
    // in runtime, which takes a CPU feature name without string quotes and
    // returns the testing result in a shape of boolean value.
    // NumPy already has macro called "NPY_CPU_HAVE", which fits this requirement.
    //
    // @param CB, a callback macro that expected to be called multiple times depending
    // on the required optimizations, the callback should receive the following arguments:
    //  1- The pending calls of @param CHK filled up with the required CPU features,
    //     that need to be tested first in runtime before executing call belong to
    //     the compiled object.
    //  2- The required optimization name, same as in 'NPY__CPU_TARGET_CURRENT'
    //  3- Extra arguments in the macro itself
    //
    // By default the callback calls are sorted depending on the highest interest
    // unless the policy "$keep_sort" was in place within the configuration statements
    // see "Dive into the CPU dispatcher" for more clarification.
    #define NPY__CPU_DISPATCH_CALL(CHK, CB, ...) \
      NPY__CPU_DISPATCH_EXPAND_(CB((CHK(AVX512F)), AVX512F, __VA_ARGS__)) \
      NPY__CPU_DISPATCH_EXPAND_(CB((CHK(SSE)&&CHK(SSE2)&&CHK(SSE3)&&CHK(SSSE3)&&CHK(SSE41)), SSE41, __VA_ARGS__))
    

    Пример использования конфигурационного заголовка в свете вышесказанного:

    // NOTE: The following macros are only defined for demonstration purposes only.
    // NumPy already has a collections of macros located at
    // numpy/numpy/_core/src/common/npy_cpu_dispatch.h, that covers all dispatching
    // and declarations scenarios.
    
    #include "numpy/npy_cpu_features.h" // NPY_CPU_HAVE
    #include "numpy/utils.h" // NPY_CAT, NPY_EXPAND
    
    // An example for setting a macro that calls all the exported symbols at once
    // after checking if they're supported by the running machine.
    #define DISPATCH_CALL_ALL(FN, ARGS) \
        NPY__CPU_DISPATCH_CALL(NPY_CPU_HAVE, DISPATCH_CALL_ALL_CB, FN, ARGS) \
        NPY__CPU_DISPATCH_BASELINE_CALL(DISPATCH_CALL_BASELINE_ALL_CB, FN, ARGS)
    // The preprocessor callbacks.
    // The same suffixes as we define it in the dispatch-able source.
    #define DISPATCH_CALL_ALL_CB(CHECK, TARGET_NAME, FN, ARGS) \
      if (CHECK) { NPY_CAT(NPY_CAT(FN, _), TARGET_NAME) ARGS; }
    #define DISPATCH_CALL_BASELINE_ALL_CB(FN, ARGS) \
      FN NPY_EXPAND(ARGS);
    
    // An example for setting a macro that calls the exported symbols of highest
    // interest optimization, after checking if they're supported by the running machine.
    #define DISPATCH_CALL_HIGH(FN, ARGS) \
      if (0) {} \
        NPY__CPU_DISPATCH_CALL(NPY_CPU_HAVE, DISPATCH_CALL_HIGH_CB, FN, ARGS) \
        NPY__CPU_DISPATCH_BASELINE_CALL(DISPATCH_CALL_BASELINE_HIGH_CB, FN, ARGS)
    // The preprocessor callbacks
    // The same suffixes as we define it in the dispatch-able source.
    #define DISPATCH_CALL_HIGH_CB(CHECK, TARGET_NAME, FN, ARGS) \
      else if (CHECK) { NPY_CAT(NPY_CAT(FN, _), TARGET_NAME) ARGS; }
    #define DISPATCH_CALL_BASELINE_HIGH_CB(FN, ARGS) \
      else { FN NPY_EXPAND(ARGS); }
    
    // NumPy has a macro called 'NPY_CPU_DISPATCH_DECLARE' can be used
    // for forward declarations any kind of prototypes based on
    // 'NPY__CPU_DISPATCH_CALL' and 'NPY__CPU_DISPATCH_BASELINE_CALL'.
    // However in this example, we just handle it manually.
    void simd_whoami(const char *extra_info);
    void simd_whoami_AVX512F(const char *extra_info);
    void simd_whoami_SSE41(const char *extra_info);
    
    void trigger_me(void)
    {
        // bring the auto-generated config header
        // which contains config macros 'NPY__CPU_DISPATCH_CALL' and
        // 'NPY__CPU_DISPATCH_BASELINE_CALL'.
        // it is highly recommended to include the config header before executing
      // the dispatching macros in case if there's another header in the scope.
        #include "hello.dispatch.h"
        DISPATCH_CALL_ALL(simd_whoami, ("all"))
        DISPATCH_CALL_HIGH(simd_whoami, ("the highest interest"))
        // An example of including multiple config headers in the same source
        // #include "hello2.dispatch.h"
        // DISPATCH_CALL_HIGH(another_function, ("the highest interest"))
    }