Prácticas recomendadas de asignación / inicialización de memoria multinúcleo / NUMA portátil

17

Cuando los cálculos limitados de ancho de banda de memoria se llevan a cabo en entornos de memoria compartida (por ejemplo, roscado a través de OpenMP, Pthreads, o TBB), hay un dilema de cómo garantizar que la memoria se distribuye correctamente a través de física de la memoria, de tal manera que cada hilo en su mayoría los accesos a memoria en una bus de memoria "local". Aunque las interfaces no son portátiles, la mayoría de los sistemas operativos tienen formas de afinidad hilo de conjunto (por ejemplo, pthread_setaffinity_np()en muchos sistemas POSIX, sched_setaffinity()en Linux, SetThreadAffinityMask()en Windows). Hay también librerías como hwloc para determinar la jerarquía de memoria, pero, por desgracia, la mayoría de los sistemas operativos todavía no proporcionan formas de políticas de la memoria NUMA conjunto. Linux es una notable excepción, con libnumapermitiendo que la aplicación manipule la política de memoria y la migración de página en granularidad de página (en línea principal desde 2004, por lo tanto ampliamente disponible). Otros sistemas operativos esperan que los usuarios observen una política implícita de "primer contacto".

Trabajar con una política de "primer contacto" significa que la persona que llama debe crear y distribuir hilos con cualquier afinidad que planeen usar más tarde cuando escriban por primera vez en la memoria recién asignada. (Muy pocos sistemas están configurados de modo que malloc()realmente encuentren páginas, solo promete encontrarlas cuando realmente tienen fallas, tal vez por diferentes subprocesos). Esto implica que la asignación que usa calloc()o inicializa inmediatamente la memoria después de la asignación memset()es dañina ya que tenderá a fallar toda la memoria en el bus de memoria del núcleo ejecutando el hilo asignación, lo que lleva a un ancho de banda de memoria peor de los casos cuando se accede a la memoria desde varios subprocesos. Lo mismo se aplica al newoperador de C ++ que insiste en inicializar muchas asignaciones nuevas (p. Ej.std::complex) Algunas observaciones acerca de este entorno:

  • Asignación puede hacerse "colectivo hilo", pero ahora la asignación se convierte en mezcla en el modelo de hilos que es indeseable para las bibliotecas que pueden tener que interactuar con los clientes utilizando diferentes modelos de subprocesamiento (tal vez cada uno con sus propios conjuntos de subprocesos).
  • RAII se considera una parte importante de C ++ idiomático, pero parece ser activamente perjudicial para el rendimiento de la memoria en un entorno NUMA. La ubicación newse puede utilizar con la memoria asignada a través de malloc()o desde rutinas libnuma, pero esto cambia el proceso de asignación (que creo que es necesario).
  • EDITAR: Mi declaración anterior sobre el operador newera incorrecta, puede soportar múltiples argumentos, vea la respuesta de Chetan. Creo que todavía existe la preocupación de que las bibliotecas o los contenedores STL utilicen una afinidad específica. Campos múltiples pueden ser empacados y puede ser un inconveniente para que, por ejemplo, un std::vectorreasigna con el gestor de contexto correcto activo.
  • Cada subproceso puede asignar y criticar su propia memoria privada, pero luego la indexación en regiones vecinas es más complicada. (Considere un producto de matriz-vector escaso con una partición de fila de la matriz y los vectores; indexar la parte no propietaria de requiere una estructura de datos más complicada cuando no es contigua en la memoria virtual).yUNXXX

Se ninguna solución a la asignación de la NUMA / inicialización considerados idiomática? ¿He dejado de lado otras trampas críticas?

(No me refiero a mi C ejemplos ++ dar a entender un énfasis en ese idioma, sin embargo, el C ++ lenguaje codifica algunas decisiones sobre la gestión de memoria que un lenguaje como C no, por lo tanto no tiende a ser más resistencia cuando lo que sugiere que los programadores de C ++ hacen los cosas diferentes)

Jed Brown
fuente

Respuestas:

7

Una solución a este problema que tiendo a preferir es hilos desagregados y tareas (MPI) en el, de manera efectiva, el nivel de controlador de memoria. Es decir, eliminar los aspectos NUMA de su código por tener una tarea por zócalo de la CPU o controlador de memoria y, a continuación subprocesos en cada tarea. Si lo hace de esa manera, debería poder vincular toda la memoria a ese zócalo / controlador de forma segura, ya sea mediante el primer toque o una de las API disponibles, sin importar qué hilo realmente haga el trabajo de asignación o inicialización. Los mensajes que pasan entre sockets generalmente están bastante optimizados, como mínimo en MPI. Siempre puede tener más tareas MPI que esta, pero debido a los problemas que plantea, rara vez recomiendo que la gente tenga menos.

Bill Barth
fuente
1
Esta es una solución práctica, pero aunque estamos obteniendo rápidamente más núcleos, el número de núcleos por nodo NUMA está bastante estancado en torno a 4. Entonces, en el hipotético nodo de 1000 núcleos, ¿ejecutaremos 250 procesos MPI? (Esto sería ideal, pero soy escéptico.)
Jed Brown
No estoy de acuerdo con que el número de núcleos por NUMA esté estancado. Sandy Bridge E5 tiene 8. Magny Cours tenía 12. Tengo un nodo Westmere-EX con 10. Interlagos (ORNL Titan) tiene 20. Knights Corner tendrá más de 50. Supongo que los núcleos por NUMA se mantienen ritmo con la Ley de Moore, más o menos.
Bill Barth el
Magny Cours y Interlagos tienen dos troqueles en diferentes regiones de la NUMA, por lo tanto 6 y 8 núcleos por región NUMA. Retroceda a 2006, donde dos zócalos de Clovertown de cuatro núcleos compartirían la misma interfaz (conjunto de chips Blackford) con la memoria y no me parece que el número de núcleos por región NUMA esté creciendo tan rápidamente. Blue Gene / Q extiende esta vista plana de la memoria un poco más y tal vez Knight's Corner dará otro paso (aunque es un dispositivo diferente, por lo que tal vez deberíamos compararnos con las GPU, donde tenemos 15 (Fermi) o ahora 8 ( Kepler) SMs viendo memoria plana).
Jed Brown
Buena decisión sobre los chips AMD. Lo había olvidado. Aún así, creo que verá un crecimiento continuo en esta área por un tiempo.
Bill Barth
6

Esta respuesta es en respuesta a dos conceptos erróneos relacionados con C ++ en la pregunta.

  1. "Lo mismo se aplica al nuevo operador de C ++ que insiste en inicializar nuevas asignaciones (incluidos los POD)"
  2. "El operador C ++ nuevo solo toma un parámetro"

No es una respuesta directa a los problemas de múltiples núcleos que mencionas. Simplemente respondiendo a los comentarios que clasifican a los programadores de C ++ como fanáticos de C ++ para que se mantenga la reputación;).

Para apuntar 1. C ++ "nuevo" o asignación de pila no insiste en inicializar nuevos objetos, ya sean POD o no. El constructor predeterminado de la clase, según lo definido por el usuario, tiene esa responsabilidad. El primer código a continuación muestra la basura impresa si la clase es POD o no.

Para el punto 2. C ++ permite sobrecarga "nuevo" con múltiples argumentos. El segundo código de abajo muestra un caso tal para la asignación de objetos individuales. Debe dar una idea y tal vez sea útil para la situación que tiene. El operador new [] también se puede modificar adecuadamente.

// Código para el punto 1.

#include <iostream>

struct A
{
    // int/double/char/etc not inited with 0
    // with or without this constructor
    // If present, the class is not POD, else it is.
    A() { }

    int i;
    double d;
    char c[20];
};

int main()
{
    A* a = new A;
    std::cout << a->i << ' ' << a->d << '\n';
    for(int i = 0; i < 20; ++i)
        std::cout << (int) a->c[i] << '\n';
}

El compilador 11.1 de Intel muestra esta salida (que por supuesto es memoria no inicializada señalada por "a").

993001483 6.50751e+029
105
108
... // skipped
97
108

// Código para el punto 2.

#include <cstddef>
#include <iostream>
#include <new>

// Just to use two different classes.
class arena { };
class policy { };

struct A
{
    void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
    {
        std::cout << "special operator new\n";
        return (void*)0x1234; //Just to test
    }
};

void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
{
    std::cout << "special operator new (global)\n";
    return (void*)0x5678; //Just to test
}

int main ()
{
    arena arena_obj;
    policy policy_obj;
    A* ptr = new(arena_obj, policy_obj) A;
    int* iptr = new(arena_obj, policy_obj) int;
    std::cout << ptr << "\n";
    std::cout << iptr << "\n";
}

fuente
Gracias por las correcciones. Parece que el C ++ no presenta complicaciones adicionales relativos a C, excepto para las matrices no POD tal como std::complexla que se inicializan de forma explícita.
Jed Brown
1
@JedBrown: ¿Razón número 6 para evitar usar std::complex?
Jack Poulson
1

En deal.II tenemos la infraestructura de software para paralelizar el ensamblaje en cada celda en múltiples núcleos utilizando Threading Building Blocks (en esencia, tiene una tarea por celda y necesita programar estas tareas en los procesadores disponibles, no es así implementado pero es la idea general). El problema es que para la integración local necesita una cantidad de objetos temporales (reutilizables) y debe proporcionar al menos la cantidad de tareas que pueden ejecutarse en paralelo. Vemos una aceleración deficiente, presumiblemente porque cuando una tarea se coloca en un procesador, toma uno de los objetos reutilizables que generalmente estarán en la caché de otro núcleo. Teníamos dos preguntas:

(i) ¿Es esta realmente la razón? Cuando ejecutamos el programa bajo cachegrind, veo que estoy usando básicamente la misma cantidad de instrucciones que cuando ejecuto el programa en un solo hilo, pero el tiempo de ejecución total acumulado en todos los hilos es mucho mayor que el de un solo hilo. ¿Es realmente porque continuamente fallo el caché?

(ii) ¿Cómo puedo averiguar dónde estoy, dónde están cada uno de los objetos reutilizables y qué objeto reutilizable necesitaría para acceder al que está caliente en el caché de mi núcleo actual?

En última instancia, no hemos encontrado respuestas a ninguna de estas soluciones y después de un par de trabajos decidimos que nos faltaban las herramientas para investigar y resolver estos problemas. Sí sé, al menos en principio, resolver el problema (ii) (es decir, usar objetos locales de subprocesos, suponiendo que los subprocesos permanecen anclados a los núcleos del procesador, otra conjetura que no es trivial para probar), pero no tengo herramientas para probar el problema (yo).

Por lo tanto, desde nuestro punto de vista, se trata de la NUMA es todavía una cuestión sin resolver.

Wolfgang Bangerth
fuente
Debe unir sus hilos a los zócalos para no tener que preguntarse si los procesadores están anclados. Linux le gusta moverse cosas.
Bill Barth
Además, muestrear getcpu () o sched_getcpu () (dependiendo de su libc y kernel y demás) debería permitirle determinar dónde se ejecutan los hilos en Linux.
Bill Barth
Sí, y creo que los bloques de creación de subprocesos que usamos para programar el trabajo en subprocesos conectan subprocesos a procesadores. Es por eso que intentamos trabajar con almacenamiento local de subprocesos. Pero aún es difícil para mí encontrar una solución a mi problema (i).
Wolfgang Bangerth
1

Más allá de hwloc, existen algunas herramientas que pueden informar sobre el entorno de memoria de un clúster HPC y que pueden usarse para establecer una variedad de configuraciones NUMA.

Recomendaría LIKWID como una de esas herramientas, ya que evita un enfoque basado en código que le permite, por ejemplo, anclar un proceso a un núcleo. Este enfoque de herramientas para abordar la configuración de memoria específica de la máquina ayudará a garantizar la portabilidad de su código entre los clústeres.

Puede encontrar una breve presentación del ISC'13 " LIKWID - Lightweight Performance Tools " y los autores han publicado un documento sobre Arxiv " Mejores prácticas para la ingeniería de rendimiento asistida por HPM en un procesador multinúcleo moderno ". Este documento describe un enfoque para interpretar los datos de los contadores de hardware para desarrollar código de rendimiento específico para la arquitectura de su máquina y la topología de la memoria.

eoinbrazil
fuente
LIKWID es útil, pero la pregunta era más sobre cómo escribir bibliotecas numéricas / sensibles a la memoria que puedan obtener y auditar de manera confiable la localidad esperada en una amplia gama de entornos de ejecución, esquemas de subprocesos, gestión de recursos MPI y configuración de afinidad, usar con otras bibliotecas, etc.
Jed Brown