Acumulación, reducción y escaneos en C++

El header <numeric> proporciona un conjunto de algoritmos diseñados para realizar operaciones matemáticas sobre rangos de elementos, evitando que escribas bucles for manuales para tareas comunes. Estos algoritmos se dividen principalmente en dos categorías: los que reducen un rango a un único valor (como sumas o productos) y los que transforman un rango en otro (como sumas parciales o diferencias).

Para entender por qué existen tantos algoritmos similares, hay que entender la diferencia entre la acumulación secuencial y la reducción paralela. std::accumulate [C++98] es el estándar de oro para operaciones que requieren un orden estricto de izquierda a derecha, ya que su evaluación es puramente secuencial: ((init + a) + b) + c. Sin embargo, esta naturaleza secuencial es un cuello de botella en sistemas con múltiples núcleos. Aquí entra std::reduce [C++17], que permite una evaluación en forma de árbol, permitiendo que el compilador o la política de ejecución divida el trabajo. El precio de esta velocidad es la exigencia de que la operación sea asociativa (el agrupamiento no importa) y conmutativa (el orden de los sumandos no importa) si quieres resultados deterministas en entornos paralelos.

Si necesitas generar secuencias numéricas rápidas, usas std::iota [C++11]. Si lo que buscas es calcular el producto punto de dos vectores, std::inner_product [C++17] es tu herramienta. Finalmente, para cálculos de “sumas acumulativas” (prefix sums), tienes std::inclusive_scan y std::exclusive_scan [C++17], que son las versiones optimizables y paralelas de lo que antes hacíamos con std::partial_sum.

Si usas std::reduce con una operación que no sea conmutativa (como la resta), el resultado será impredecible dependiendo de cómo el hardware divida el rango. Si usas std::accumulate en un bucle de procesamiento masivo de datos donde la velocidad es crítica, estarás desperdiciando la capacidad de cómputo de tu CPU.

#include <iostream>
#include <vector>
#include <numeric>    // Algoritmos numéricos
#include <execution>  // Políticas de ejecución (C++17)
#include <algorithm>
#include <ranges>

int main() {
    // Datos de ejemplo: Precios de productos
    std::vector<double> precios = {10.50, 20.00, 5.25, 15.75, 40.00};
    // Datos para producto punto: Cantidades compradas
    std::vector<double> cantidades = {2.0, 1.0, 10.0, 1.0, 2.0};

    // 1. Generación de secuencias con std::iota [C++11]
    // Llenamos un vector con valores consecutivos empezando en 1
    std::vector<int> ids(5);
    std::iota(ids.begin(), ids.end(), 1);

    // 2. Suma total secuencial con std::accumulate [C++98]
    // Garantiza orden de izquierda a derecha.
    double total_secuencial = std::accumulate(precios.begin(), precios.end(), 0.0);

    // 3. Suma total paralela con std::reduce [C++17]
    // Usamos la política de ejecución paralela. 
    // Requiere que la suma sea conmutativa para ser determinista.
    // Nota: Para compilar con GCC/Clang, puede requerir -ltbb
    double total_paralelo = std::reduce(std::execution::par, precios.begin(), precios.end(), 0.0);

    // 4. Producto punto con std::inner_product [C++17]
    // Calcula: (p[0]*c[0]) + (p[1]*c[1]) + ...
    double subtotal_puntos = std::inner_product(precios.begin(), precios.end(), cantidades.begin(), 0.0);

    // 5. Sumas parciales (Prefix Sums)
    // inclusive_scan: [10, 15, 20] -> [10, 25, 45]
    std::vector<double> running_total(precios.size());
    std::inclusive_scan(std::execution::par, precios.begin(), precios.end(), running_total.begin());

    // exclusive_scan: [10, 15, 20] -> [0, 10, 25]
    std::vector<double> exclusive_total(precios.size());
    std::exclusive_scan(precios.begin(), precios.end(), exclusive_total.begin(), 0.0);

    // 6. Diferencias entre elementos consecutivos con std::adjacent_difference
    std::vector<double> variaciones(precios.size());
    std::adjacent_difference(precios.begin(), precios.end(), variaciones.begin());

    // Mostrar resultados
    std::cout << "Total (Accumulate): " << total_secuencial << "\n";
    std::cout << "Total (Reduce PAR): " << total_paralelo << "\n";
    std::cout << "Producto Punto:      " << subtotal_puntos << "\n";
    std::cout << "Running Total:       ";
    for (auto v : running_total) std::cout << v << " "; 
    std::cout << "\nVariaciones:        ";
    for (auto v : variaciones) std::cout << v << " ";
    std::cout << std::endl;

    return 0;
}

Desglose del código

En el ejemplo, hemos orquestado varios algoritmos para procesar los datos de ventas. Al llamar a std::iota, el compilador rellena el vector ids en una secuencia lineal simple.

La diferencia clave ocurre entre std::accumulate y std::reduce. Aunque ambos nos devuelven la suma de precios, std::accumulate lo hace de forma estrictamente lineal, lo cual es seguro para cualquier operación binaria. En cambio, al usar std::reduce con std::execution::par, el runtime puede segmentar el vector precios en múltiples hilos. Si el vector fuera masivo, esto aprovecharía los múltiples núcleos de la CPU. Para que esto sea seguro y determinista, la suma debe ser conmutativa; si el orden de los sumandos cambiara el resultado (debido a precisiones de punto flotante, por ejemplo), reduce podría dar un resultado ligeramente distinto en cada ejecución si el escalado de hilos varía.

std::inner_product realiza lo que en álgebra lineal llamamos el producto escalar. Internamente, es un bucle que multiplica elementos de dos rangos y los va acumulando en un valor inicial. Es más eficiente y legible que escribir el bucle manualmente.

Para las sumas acumulativas, std::inclusive_scan es la evolución moderna de std::partial_sum. Al pasarle std::execution::par, permitimos que la suma parcial se calcule de forma más eficiente mediante una estructura de árbol de reducción (prefix sum parallel algorithms), algo que std::partial_sum no puede hacer por su naturaleza puramente secuencial. Finalmente, std::adjacent_difference calcula la diferencia entre un elemento y su predecesor, útil para detectar cambios en series temporales.

El error frecuente

Un error crítico es intentar usar std::reduce con operaciones que no son conmutativas, como la resta, esperando un resultado consistente.

std::vector<int> nums = {10, 2, 5};

// Correcto: Accumulate (secuencial, garantiza (10-2)-5 = 3)
int res_acc = std::accumulate(nums.begin(), nums.end(), 0, std::minus<int>());

// Peligroso: Reduce (paralelo, el orden puede ser (10-2)-5 o 10-(2-5))
// El resultado es indeterminado (Undefined Behavior en términos de lógica de negocio)
int res_red = std::reduce(std::execution::par, nums.begin(), nums.end(), 0, std::minus<int>());

En el caso de std::reduce con std::minus, el resultado depende de cómo se dividan los sub-rangos. Si el sistema decide procesar primero (2-5) y luego restarlo de 10, el resultado será 13 en lugar de 3. Si usas un analizador como ThreadSanitizer, no detectará esto como un error de carrera de memoria (race condition), pero tu lógica de negocio será errática. Para operaciones no conmutativas, quédate con std::accumulate.

74

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio