Ir al contenido

¿Puede fallar el assert? Modelo de memoria

teoria/ejemplo01_assert.cpp
class C {
int x = 0;
int y = 0;
public:
void f() {
x = 1; // A
y = 1; // B
}
void g() {
int a = y; // C
int b = x; // D
assert(b >= a);
}
};
int main() {
int i = 1;
while (true) {
C c;
#pragma omp parallel
{
#pragma omp sections
{
#pragma omp section
c.f(); // un hilo
#pragma omp section
c.g(); // otro hilo
}
}
if (++i % 1000 == 0) cout << i << endl;
}
}

f() y g() corren a la vez en hilos distintos. Dentro de cada hilo, A va antes que B y C antes que D. Entre hilos, cualquier mezcla es posible.

Con 2 hilos de k instrucciones cada uno, un intercalado es una lista de 2k posiciones donde eliges cuáles k son del hilo 1 (el orden interno ya está fijo):

número de intercalados = C(2k, k) = (2k)! / (k!)^2 ≈ 4^k / sqrt(pi·k)

Para k = 2: C(4, 2) = 6: ABCD, ACBD, ACDB, CABD, CADB, CDAB. Crece casi como 4^k: por eso no se puede probar un programa paralelo “probando todos los casos”.

Demostración por contradicción (lámina 4)

Sección titulada «Demostración por contradicción (lámina 4)»

Supón que el assert falla: b < a, o sea a = 1 y b = 0.

  1. a = 1 ⇒ a = y leyó después de y = 1: B antes que C.
  2. b = 0 ⇒ b = x leyó antes de x = 1: D antes que A.
  3. Orden del programa en cada hilo: A antes que B y C antes que D.
  4. Juntando: A → B → C → D → A. Es un ciclo: A tendría que ir antes que sí misma. Contradicción.

Conclusión de la lámina: si cada hilo ejecuta en el orden del código, el assert nunca falla.

¿Puede fallar assert(b >= a)?

Un hilo ejecuta f() y otro g(). Recorre todos los intercalados posibles. Después activa los reordenamientos que puede hacer el compilador con -O2 y mira qué cambia.

void f() {   // hilo 0
    x = 1;  // A
    y = 1;  // B
}
void g() {   // hilo 1
    int a = y;  // C
    int b = x;  // D
    assert(b >= a);
}
instrucción 0/4
A · hilo 0x = 1
B · hilo 0y = 1
C · hilo 1a = y
D · hilo 1b = x
x0
y0
a—
b—
intercalados = C(4,2)6
fallan0
Con el orden del código fuente, ninguno de los 6 intercalados falla: es la demostración por contradicción de la lámina.

Compilamos el código de clase tal cual y lo dejamos correr 3 segundos, 10 veces:

CompilaciónCorridas donde falló el assert
g++ -fopenmp -O00 / 10
g++ -fopenmp -O210 / 10 (Assertion 'b >= a' failed, en menos de 1000 iteraciones)

¿Por qué? Mirando el ensamblador que genera -O2:

g() compilado con -O2 (x86-64)
movl (%rax), %ecx ; lee x ← esto es D
cmpl %ecx, 4(%rax) ; lee y y compara ← esto es C
jle ... ; si y <= x, todo bien
call __assert_fail

El compilador leyó x antes que y: hizo D antes que C. En un programa secuencial da lo mismo (nadie más toca x ni y), así que es una optimización válida. Y en f() juntó x = 1 e y = 1 en una sola escritura de 8 bytes. Con el orden D, (A+B), C se obtiene b = 0 y a = 1: el assert falla.

hilo 1: f()hilo 2: g()A: x = 1B: y = 1orden en el códigoC: a = yD: b = xorden en que la memoria compartida ve las operacionesB: y = 1C lee y → a = 1D lee x → b = 0A: x = 1 (tarde)↑ A se hizo visible tardea = 1, b = 0 → assert(b ≥ a) fallasin sincronización (compilador o store buffer), A y B (que no dependen entre sí) pueden hacerse visibles en otro orden

Cómo leerlo: arriba, el orden que escribiste: A antes que B en el hilo 1, C antes que D en el hilo 2. Abajo, el orden en que la memoria compartida ve esas operaciones en una ejecución que falla: la escritura de x (A) se hace visible tarde. El hilo 2 ve y = 1 pero todavía x = 0, así que a = 1, b = 0 y el assert falla. Ningún intercalado “en orden” da ese resultado; aparece porque el compilador o el hardware reordenan A y B, que no dependen entre sí.

Las tres versiones de f() son equivalentes en secuencial:

void f() { x = 1; y = x + 1; z = x + 1; } // original
void f() { x = 1; z = x + 1; y = x + 1; } // reordenada
void f() { x = 1; z = 2; y = 2; } // constantes propagadas

Si otro hilo ejecuta g() { x = 5; } justo después de x = 1:

  • en la segunda versión puede salir x = 1, z = 2, y = 6;
  • en la tercera, y nunca puede valer 6 (ya no lee x).

Mismo programa secuencial, resultados paralelos distintos.

El segundo ejemplo es una espera activa:

int x = 0;
void wait() { x = 1; while (x == 1); } // espera a que otro hilo cambie x
void arrive() { x = 2; }

Con -O3 el compilador ve que en wait() nadie cambia x dentro del bucle, saca la lectura del bucle (ya sabe que x == 1) y lo convierte en un bucle infinito (jmp a sí mismo). Aunque otro hilo llame a arrive(), wait() nunca termina.

Resumen de la lámina 7: los compiladores modernos garantizan un orden local (por hilo), no global, y con eso consiguen mejoras de 3 a 10×:

  • eliminan o mueven sentencias completas;
  • el procesador también reordena accesos a memoria (pipeline: varias instrucciones a la vez);
  • cada núcleo tiene su caché: una escritura puede tardar en ser visible para otros hilos.
hilo 0 hilo 1
[registros] [registros]
↕ ↕
[caché L1/L2] [caché L1/L2]
↘ ↙
[ memoria RAM ]

Un valor puede estar en un registro de un hilo (como x en el bucle de wait()), en su caché o en la RAM. Lo que un hilo escribe no aparece de inmediato en la vista del otro.

Un modelo de memoria (por ejemplo, el de C++) define qué resultados son válidos:

ConceptoQué significa
Orden del programa (program order)Dentro de un hilo hay un orden lógico entre las operaciones.
Consistencia de memoriaQué valores puede ver un hilo cuando otros escriben a la vez.
Condición de carrera (data race)Dos hilos acceden a la misma posición, al menos uno escribe, y los accesos no están ordenados ni sincronizados.
Happens-beforeRelación de orden entre operaciones de hilos distintos. Si una escritura happens-before una lectura, la lectura debe ver ese valor (o uno posterior). Se crea con sincronización.

OpenMP crea relaciones happens-before con: barrier, critical, atomic, task depend, reduction, flush.

teoria/ejemplo01_assert_critical.cpp
#pragma omp parallel
{
#pragma omp critical
c.f();
#pragma omp critical
c.g();
}

Con critical, f() y g() ya no se mezclan: cada una corre entera antes o después de la otra, y hay flush al entrar y salir. Se ejecutó 5 segundos con -O2 y el assert no falló (llegó a la iteración 16 000).

En el bucle de wait(), ¿qué dos formas de OpenMP evitan que el compilador deje x en un registro?

Ver solución

Cualquier construcción que fuerce un flush de x en cada vuelta:

void wait() {
x = 1;
int v;
do {
#pragma omp atomic read
v = x; // lectura atómica: siempre va a memoria
} while (v == 1);
}
void arrive() {
#pragma omp atomic write
x = 2;
}

O bien #pragma omp flush(x) antes de leer x en cada iteración (y después de escribir en arrive). En C++ moderno lo equivalente es std::atomic<int> x.

  • “Revisé todos los intercalados y ninguno falla, entonces es correcto”: sin sincronización, el compilador y el hardware pueden reordenar y aparecen resultados que ningún intercalado explica.
  • Creer que volatile sincroniza en C++: evita que el compilador guarde la variable en un registro, pero no crea happens-before ni impide el reordenamiento del hardware.
  • Tomar como prueba que un programa con carrera “pasó 10 000 veces”: el error puede depender del optimizador, de la máquina o de la carga.

Con la versión critical (f() y g() cada una en su propia critical), ¿qué pares (a, b) son posibles?

Ver solución

Solo dos: si g() corre antes que f(), (0, 0); si f() corre antes, (1, 1). critical hace que cada función corra entera sin mezclarse y pone flush al entrar y salir, así que cuando g() ve y = 1 también ve x = 1. El par (1, 0) es imposible, y el assert se cumple siempre.

Autoevaluación

0/5 correctas · 0 respondidas
1. Dos hilos con 3 instrucciones cada uno. ¿Cuántos intercalados respetan el orden de cada hilo?
2. En el ejemplo del assert, ¿qué supuesto usa la demostración por contradicción?
3. ¿Por qué falla el assert con -O2?
4. Un data race requiere…
5. ¿Cuál NO crea una relación happens-before en OpenMP?

Fuente ·U4.4 Paralelismo compartido-OMP.pdf (láminas 2–10) · semana_10/teoria/ejemplo01_assert.cpp · semana_10/teoria/ejemplo01_assert_critical.cpp