Transferencia de matrices / clases / registros entre locales
En una simulación típica de N-Body, al final de cada época, cada localidad necesitaría compartir su propia porción del mundo (es decir, todos los cuerpos) con el resto de las localizaciones. Estoy trabajando en esto con un enfoque de vista local (es decir, usando on Loc
declaraciones). Encontré algunos comportamientos extraños que no podía entender, así que decidí hacer un programa de prueba, en el que las cosas se complicaron más. Aquí está el código para replicar el experimento.
proc log(args...?n) {
writeln("[locale = ", here.id, "] [", datetime.now(), "] => ", args);
}
const max: int = 50000;
record stuff {
var x1: int;
var x2: int;
proc init() {
this.x1 = here.id;
this.x2 = here.id;
}
}
class ctuff {
var x1: int;
var x2: int;
proc init() {
this.x1 = here.id;
this.x2 = here.id;
}
}
class wrapper {
// The point is that total size (in bytes) of data in `r`, `c` and `a` are the same here, because the record and the class hold two ints per index.
var r: [{1..max / 2}] stuff;
var c: [{1..max / 2}] owned ctuff?;
var a: [{1..max}] int;
proc init() {
this.a = here.id;
}
}
proc test() {
var wrappers: [LocaleSpace] owned wrapper?;
coforall loc in LocaleSpace {
on Locales[loc] {
wrappers[loc] = new owned wrapper();
}
}
// rest of the experiment further down.
}
Aquí ocurren dos comportamientos interesantes.
1. Mover datos
Ahora, cada instancia de wrapper
in array wrappers
debería vivir en su configuración regional. Específicamente, las referencias ( wrappers
) vivirá en locale 0, pero los datos internos ( r
, c
, a
) debe vivir en la respectiva configuración regional. Así que intentamos mover algunos de la configuración regional 1 a la 3, como tal:
on Locales[3] {
var timer: Timer;
timer.start();
var local_stuff = wrappers[1]!.r;
timer.stop();
log("get r from 1", timer.elapsed());
log(local_stuff);
}
on Locales[3] {
var timer: Timer;
timer.start();
var local_c = wrappers[1]!.c;
timer.stop();
log("get c from 1", timer.elapsed());
}
on Locales[3] {
var timer: Timer;
timer.start();
var local_a = wrappers[1]!.a;
timer.stop();
log("get a from 1", timer.elapsed());
}
Sorprendentemente, mis tiempos muestran que
Independientemente del tamaño (
const max
), el tiempo de envío de la matriz y el registro es constante, lo que no tiene sentido para mí. Incluso lo verifiquéchplvis
, y el tamaño de enGET
realidad aumenta, pero el tiempo sigue siendo el mismo.El tiempo para enviar el campo de la clase aumenta con el tiempo, lo cual tiene sentido, pero es bastante lento y no sé en qué caso confiar aquí.
2. Consultar las configuraciones regionales directamente.
Para desmitificar el problema, también consulto .locale.id
directamente algunas variables. Primero, consultamos los datos, que esperamos que vivan en el entorno local 2, desde el entorno local 2:
on Locales[2] {
var wrappers_ref = wrappers[2]!; // This is always 1 GET from 0, okay.
log("array",
wrappers_ref.a.locale.id,
wrappers_ref.a[1].locale.id
);
log("record",
wrappers_ref.r.locale.id,
wrappers_ref.r[1].locale.id,
wrappers_ref.r[1].x1.locale.id,
);
log("class",
wrappers_ref.c.locale.id,
wrappers_ref.c[1]!.locale.id,
wrappers_ref.c[1]!.x1.locale.id
);
}
Y el resultado es:
[locale = 2] [2020-12-26T19:36:26.834472] => (array, 2, 2)
[locale = 2] [2020-12-26T19:36:26.894779] => (record, 2, 2, 2)
[locale = 2] [2020-12-26T19:36:27.023112] => (class, 2, 2, 2)
Lo que se espera. Sin embargo, si consultamos la configuración regional de los mismos datos en la configuración regional 1, obtenemos:
[locale = 1] [2020-12-26T19:34:28.509624] => (array, 2, 2)
[locale = 1] [2020-12-26T19:34:28.574125] => (record, 2, 2, 1)
[locale = 1] [2020-12-26T19:34:28.700481] => (class, 2, 2, 2)
Lo que implica que wrappers_ref.r[1].x1.locale.id
vive en la ubicación 1, aunque claramente debería estar en la ubicación 2 . Mi única suposición es que para cuando .locale.id
se ejecuta, los datos (es decir, .x
del registro) ya se han movido a la configuración regional de consulta (1).
Entonces, en general, la segunda parte del experimento conduce a una pregunta secundaria, sin responder a la primera parte.
NOTA: todos los experimentos se ejecutan -nl 4
en la chapel/chapel-gasnet
imagen de la ventana acoplable.
Respuestas
Buenas observaciones, déjame ver si puedo arrojar algo de luz.
Como nota inicial, cualquier tiempo tomado con la imagen de Gasnet Docker debe tomarse con un grano de sal, ya que esa imagen simula la ejecución en múltiples nodos usando su sistema local en lugar de ejecutar cada configuración regional en su propio nodo de cómputo como se pretende en Chapel. Como resultado, es útil para desarrollar programas de memoria distribuida, pero es probable que las características de rendimiento sean muy diferentes a las que se ejecutan en un clúster o supercomputadora real. Dicho esto, aún puede ser útil para obtener tiempos aproximados (por ejemplo, su observación de "esto está tardando mucho más tiempo") o para contar las comunicaciones utilizando chplvis
o el módulo CommDiagnostics .
Con respecto a sus observaciones sobre los tiempos, también observo que el caso de la matriz de clases es mucho más lento y creo que puedo explicar algunos de los comportamientos:
Primero, es importante entender que cualquier comunicación entre nodos se puede caracterizar usando una fórmula como alpha + beta*length
. Piense alpha
que representa el costo básico de realizar la comunicación, independientemente de la duración. Esto representa el costo de llamar a través de la pila de software para llegar a la red, poner los datos en el cable, recibirlos en el otro lado y volver a subirlos a través de la pila de software a la aplicación allí. El valor exacto de alfa dependerá de factores como el tipo de comunicación, la elección de la pila de software y el hardware físico. Mientras tanto, piense beta
que representa el costo por byte de la comunicación donde, como intuye, los mensajes más largos cuestan necesariamente más porque hay más datos para poner en el cable, o potencialmente para almacenar en búfer o copiar, dependiendo de cómo se implemente la comunicación.
En mi experiencia, el valor de alpha
típicamente domina beta
para la mayoría de las configuraciones del sistema. Eso no quiere decir que sea gratis realizar transferencias de datos más largas, pero que la variación en el tiempo de ejecución tiende a ser mucho menor para transferencias más largas frente a transferencias más cortas que para realizar una sola transferencia frente a muchas. Como resultado, al elegir entre realizar una transferencia de n
elementos o n
transferencias de 1 elemento, casi siempre querrá la primera.
Para investigar sus tiempos, coloqué entre corchetes sus partes de código cronometrado con llamadas al CommDiagnostics
módulo de la siguiente manera:
resetCommDiagnostics();
startCommDiagnostics();
...code to time here...
stopCommDiagnostics();
printCommDiagnosticsTable();
y descubrió, como hizo con chplvis
, que la cantidad de comunicaciones necesarias para localizar la matriz de registros o la matriz de entradas era constante a medida que variaba max
, por ejemplo:
lugar | obtener | ejecutar_en |
---|---|---|
0 | 0 | 0 |
1 | 0 | 0 |
2 | 0 | 0 |
3 | 21 | 1 |
Esto es consistente con lo que esperaría de la implementación: que para una matriz de tipos de valor, realizamos un número fijo de comunicaciones para acceder a los metadatos de la matriz y luego comunicamos los elementos de la matriz en una sola transferencia de datos para amortizar la gastos generales (evite pagar múltiples alpha
costos).
En contraste, encontré que el número de comunicaciones para localizar la matriz de clases era proporcional al tamaño de la matriz. Por ejemplo, para el valor predeterminado de 50.000 para max
, vi:
lugar | obtener | poner | ejecutar_en |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 0 | 0 | 0 |
2 | 0 | 0 | 0 |
3 | 25040 | 25000 | 1 |
Creo que la razón de esta distinción se relaciona con el hecho de que se c
trata de una matriz de owned
clases, en la que solo una única variable de clase puede "poseer" un ctuff
objeto dado a la vez. Como resultado, al copiar los elementos de una matriz c
de una configuración regional a otra, no solo está copiando datos sin procesar, como con los casos de registros y enteros, sino que también está realizando una transferencia de propiedad por elemento. Básicamente, esto requiere establecer el valor remoto en nil
después de copiar su valor en la variable de clase local. En nuestra implementación actual, esto parece hacerse usando un control remoto get
para copiar el valor de la clase remota al local, seguido de un control remoto put
para establecer el valor remoto nil
, por lo tanto, tenemos un elemento get y put por matriz, lo que resulta en O (n) comunicaciones en lugar de O (1) como en los casos anteriores. Con un esfuerzo adicional, podríamos hacer que el compilador optimizara este caso, aunque creo que siempre será más caro que los demás debido a la necesidad de realizar la transferencia de propiedad.
Probé la hipótesis de que las owned
clases generaban una sobrecarga adicional al cambiar sus ctuff
objetos de ser owned
a unmanaged
, lo que elimina cualquier semántica de propiedad de la implementación. Cuando hago esto, veo un número constante de comunicaciones, como en los casos de valor:
lugar | obtener | ejecutar_en |
---|---|---|
0 | 0 | 0 |
1 | 0 | 0 |
2 | 0 | 0 |
3 | 21 | 1 |
Creo que esto representa el hecho de que una vez que el lenguaje no tiene necesidad de administrar la propiedad de las variables de clase, simplemente puede transferir sus valores de puntero en una sola transferencia nuevamente.
Más allá de estas notas de rendimiento, es importante comprender una diferencia semántica clave entre clases y registros al elegir cuál usar. Un objeto de clase se asigna en el montón y una variable de clase es esencialmente una referencia o puntero a ese objeto. Por lo tanto, cuando una variable de clase se copia de un lugar a otro, solo se copia el puntero y el objeto original permanece donde estaba (para bien o para mal). Por el contrario, una variable de registro representa el objeto en sí, y se puede pensar que está asignada "en el lugar" (por ejemplo, en la pila para una variable local). Cuando una variable de registro se copia de un entorno local a otro, es el objeto en sí (es decir, los valores de los campos del registro) el que se copia, lo que da como resultado una nueva copia del objeto en sí. Consulte esta pregunta SO para obtener más detalles.
Pasando a su segunda observación, creo que su interpretación es correcta y que esto puede ser un error en la implementación (necesito insistir un poco más para tener confianza). Específicamente, creo que tiene razón en que lo que está sucediendo es que wrappers_ref.r[1].x1
se está evaluando, con el resultado almacenado en una variable local, y que la .locale.id
consulta se está aplicando a la variable local que almacena el resultado en lugar del campo original. Probé esta teoría llevando un ref
al campo y luego imprimiendo locale.id
esa referencia, de la siguiente manera:
ref x1loc = wrappers_ref.r[1].x1;
...wrappers_ref.c[1]!.x1.locale.id...
y eso pareció dar el resultado correcto. También miré el código generado que parecía indicar que nuestras teorías eran correctas. No creo que la implementación deba comportarse de esta manera, pero necesito pensarlo un poco más antes de tener confianza. Si desea abrir un error en contra de esto en la página de problemas de GitHub de Chapel , para una mayor discusión allí, lo agradeceríamos.