Skrivet av klk:
Om jag då tar ett exempel från verkligheten för att beskriva hur C++ utvecklare ofta tänker.
Följande förutsättningar gäller.
En lösning kräver mycket hög prestanda och måste skala till så många kärnor som möjligt. För att lyckas skala till många kärnor delas data upp i "tårtbitar" om uttrycket tillåts. Mindre isolerade enheter. Varje liten den kan jobba isolerat innan det synkas.
För att lösa det krävs en hel del kod endast för att beräkna hur arbeten skall delas upp, både vad gäller data och hur många tårtbitar. Programmet får inte kompileras om, en installation måste fungera i många olika sammanhang.
Alla "rörliga delar" flyttas därför ut och görs så enkla som möjligt så programmet blir användbart.
Bröt ut denna, detta är helt klart en av mina "darlings". Spenderade väldigt mycket tid på detta under 2010-talet!
Tror tyvärr för din egen del att detta är ett fall där du "tänker som en C++-programmerare från 90-talet". Detta är ett mycket komplex område, men det är också ett område det skett enormt mycket utveckling från början av 2010-talet fram till nu (och sker forfarande mycket, man har långt ifrån "löst" detta än).
Låt oss vara konkreta och använda "leksaksexempel" för att visa hur man kan lösa detta på olika sätt. Något som ofta används i exemplen här är den "matematiskt korrekta, men effektivitedtsmässigt korkade implementationer av att beräkna Fibonacci-serien".
I C/C++
int fib(int n) {
if (n < 2)
return n;
return fib(n - 1) + fib(n - 2)
}
Den är användbart i illustrationer av flera skäl. Dels är den otroligt enkel i sin implementation, den väldigt mycket resurser att beräkna vid relativt små värden på "n", den går att köra parallellt dels över individuella indata (den är "pure functional") men också köras parallellt internt då varje rekursivt anrop också är helt oberoende.
Med Rust är det direkt trivialt att få ur alla perspektiv "perfekt" skalning med CPU-kärnor om man vill beräkna Fibonacci över en serie värden. Ser ut så här
Klicka för mer information
use rand::Rng;
use rayon::prelude::*;
use std::time::Instant;
const PAR_THRESHOLD: u32 = 30;
fn serial_fib(n: u32) -> u32 {
if n <= 1 {
return n;
}
serial_fib(n - 1) + serial_fib(n - 2)
}
fn parallel_fib(n: u32) -> u32 {
if n < PAR_THRESHOLD {
return serial_fib(n); // Too much parallellism will kill performance, Amdahl shows again...
}
let (fib_n1, fib_n2) = rayon::join(
|| parallel_fib(n - 1),
|| parallel_fib(n - 2));
fib_n1 + fib_n2
}
fn main() {
let num_elements = 1000;
let ser_min_n = 30;
let ser_max_n = 36;
let par_n = 49;
let mut rng = rand::rng();
let ns: Vec<u32> = (0..num_elements)
.map(|_| rng.random_range(ser_min_n..=ser_max_n))
.collect();
let start_serial_series = Instant::now();
let _: Vec<u32> = ns
.iter()
.map(|&n| serial_fib(n))
.collect::<Vec<u32>>();
let duration_serial = start_serial_series.elapsed();
println!(
"Serial execution time of {} elements: {:?}",
num_elements, duration_serial
);
let start_parallel_series = Instant::now();
let _: Vec<u32> = ns
.par_iter() // <--- Only change required to run in parallel!
.map(|&n| serial_fib(n))
.collect();
let duration_parallel = start_parallel_series.elapsed();
println!(
"Parallel execution time of {} elements: {:?}",
num_elements, duration_parallel
);
let start_serial_fib = Instant::now();
let _ = serial_fib(par_n);
let duration_serial_fib = start_serial_fib.elapsed();
println!(
"Serial Fibonacci execution time for n = {}: {:?}",
par_n, duration_serial_fib
);
let start_parallel_fib = Instant::now();
let _ = parallel_fib(par_n);
let duration_parallel_fib = start_parallel_fib.elapsed();
println!(
"Parallel Fibonacci execution time for n = {}: {:?}",
par_n, duration_parallel_fib
);
}
Visa mer
Resultat från min dator med 12 "P-kärnor" och 4 "E-kärnor"
Serial execution time of 1000 elements: 13.501740667s
Parallel execution time of 1000 elements: 1.076859417s
Serial Fibonacci execution time for n = 49: 19.009579541s
Parallel Fibonacci execution time for n = 49: 1.493294458s
Ovan exempel är logiskt rätt lätt att översätta till Go (channels+goroutines), C#(TPL/Parallel.For). Båda ser riktigt bra skalning, men det når inte riktigt upp till Rust/Rayon.
Går även att göra med OpenMP i C, C++ och Fortran. Här är effektiviteten nära till lika med Rayon, men är i min mening långt mer komplicerat och otroligt lätt att gå bort sig i exakt hur/var man stoppar in sina "#pragma omg..." direktiv för att resultatet ska bli det man vill ha...
I teorin kan det första fallet göras i standard C++17 och framåt. I praktiken tycker jag std::execution::* så här långt både haft brister i prestanda och är inte alls säkert att det överhuvudtaget gör något (i.e. det kompilerar men inget körs parallellt, det är bara "hints" så korrekt enligt C++17 spec).
Sannolikheten att du lyckas göra något eget som matchar det som är inbyggt i Go, C#, Java, m.fl. och som implementeras av bibliotek som Rust/Rayon, C/C++/Fortran/OpenMP är väldigt väldigt nära noll.
Och håller du inte med om att med något som Rust Rayon är det inte bara "minnessäkert", det är faktiskt hyfsat enkelt också!
Fråga: hur löser en C++-programmerare detta, om vi tar bort OpenMP (där vet jag hur man gör)?