C++ och dess framtid att programmera minnessäkert - Hur går utvecklingen?

Permalänk
Skrivet av klk:

Förutom load, vad är det mer för villkor som måste uppfyllas för att du skall kunna köra simd-operationer?

AVX är instruktioner som alla andra, skillnaden är att de opererar på vektorregister istället för skalär-register. De kan ha en minnesoperand, precis som "vanliga" operationer. Om du får en cache-miss kommer de vänta tills data har kommit från nästa nivå i cache-hierarkin, precis som "vanliga" instruktioner. Det är inga speciella villkor som skall vara uppfyllda, bortsett från att de opererar på vektor-register.

Skrivet av klk:

ok. du tror det. Så vad är simd-vänligt enligt dig?

Skulle du kunna visa exempel på kod där simd fungerar bra

När vi andra har pratat om DOD så handlar det om en stil där man bryter ut data, exempelvis koordinater, i SoA-style för att man skall kunna utföra samma operation på alla element i en array. Detta är SIMD-vänligt. Har du alla x-koordinater samlade i en array och alla y-koordinater i en annan är det ett smash-upplägg för att köra SIMD-kod. Användaren drog bilden åt ett håll, OK alla x-koordinater skall justeras med ett (samma) värde (samma operation på alla element => SIMD!, data samlat => cache-lokalitet!) och alla y-koordinater skall justeras med ett annat värde.

Din tolkning av DOD där du fyller tabellerna med mixade data lämpar sig inte alls för SIMD. Där kan man inte utföra samma operation på konsekutiva data. Är du med på skillnaden?

Permalänk
Medlem
Skrivet av Ingetledigtnamn:

AVX är instruktioner som alla andra

AVX är simd teknik och simd står för "Single instruction, multiple data", observera Single. Om det inte varit för single utan att man kunnat göra nära nog vad som helst sett till de operationer en processor har så då jäsiken, då hade simd varit otroligt effektivt.

Nu är det inte så användbart mer än i ganska speciella situationer och problemet är att det behöver vara EN och samma instruktion.
Programmerare måste skriva koden specifikt för det här.

Ställer man in kompilatorer för att generera SIMD instruktioner tror inte jag det är vanligt att de "hittar" områden där de kan applicera AVX512 och då tänker jag på att utföra 8/16 operationer samtidigt men kanske använder annat i senaste AVX512 som bättrar på. Tror det är mycket vanligare att de hittar optimeringar för mindre block.

Med lite övning går det ändå att ha en hum om hur data bör ligga för att kompilatorn skall kunna göra sådana här optimeringar.

Skrivet av Ingetledigtnamn:

När vi andra har pratat om DOD så handlar det om en stil där man bryter ut data, exempelvis koordinater, i SoA-style för att man skall kunna utföra samma operation på alla element i en array. Detta är SIMD-vänligt.

Exakt

Skrivet av Ingetledigtnamn:

Din tolkning av DOD där du fyller tabellerna med mixade data lämpar sig inte alls för SIMD. Där kan man inte utföra samma operation på konsekutiva data. Är du med på skillnaden?

Men halle jösses då. Vi (jag) har bara skrapat på ytan för att försöka förklara principerna.

Taggad data kan göras på massor av sätt. Du kan exempelvis ha två olika block, ett block med taggningen som beskriver data i det andra blocket ligger data. På det viset kan man till och med få simd att fungerar på taggarna och datat.

Permalänk
Skrivet av klk:

AVX är simd teknik och simd står för "Single instruction, multiple data", observera Single. Om det inte varit för single utan att man kunnat göra nära nog vad som helst sett till de operationer en processor har så då jäsiken, då hade simd varit otroligt effektivt.

Varför klippte du bort "skillnaden är att de opererar på vektorregister istället för skalär-register" ur citatet? De utför en operation på alla elementen i vektorregistret. Att det är SIMD-operationer, that goes without saying. Vad tycker du att Single medför för villkor?

Om du efterlyser instruktioner som kan göra olika saker med olika register i samma instruktion får du leta efter VLIW. Men behövs det?

Om du tittar på ett blockdiagram för en modern processor så finns det en hel rad med olika stackar av ALUer med lite olika operationer. Golden Cove kan köra upp till åtta av dessa parallellt varje cykel. Åtta operationer var det inte det du ville ha? En modern CPU är redan jäsiken så effektiv.

(By Saneandsad - Data from https://chipsandcheese.com/ and Intel Architecture Day 2021 presentation. All non-official data are measured estimates., CC BY-SA 4.0, https://commons.wikimedia.org/w/index.php?curid=113727886)

Skrivet av klk:

Nu är det inte så användbart mer än i ganska speciella situationer och problemet är att det behöver vara EN och samma instruktion.
Programmerare måste skriva koden specifikt för det här.

Exakt. Den specifika konstruktionen som programmeraren måste använda kallas loop. Så länge den opererar på data av samma typ, med regelbundna strides kommer kompilatorn kunna vektorisera loopen åt dig.

Skrivet av klk:

Med lite övning går det ändå att ha en hum om hur data bör ligga för att kompilatorn skall kunna göra sådana här optimeringar.

Som sagt, samma operation på konsekutiva data. Svårare än så är det inte. Om du har mixade data i dina tabeller lämpar de sig inte för SIMD.

Permalänk
Medlem
Skrivet av Ingetledigtnamn:

Varför klippte du bort "skillnaden är att de opererar på vektorregister istället för skalär-register" ur citatet?

För att det bara gör diskussionen svår och tycker att om du blandar in "svåra ord" så beskriv gärna andra slipper söka. Att SIMD har sina egna register istället för de register som för vanliga operationer hade räckt. Det är inte relevant i sammanhanget vi diskuterar just nu. Det där är mer hur det är tekniskt löst i processorn.

Skrivet av Ingetledigtnamn:

De utför en operation på alla elementen i vektorregistret. Att det är SIMD-operationer, that goes without saying. Vad tycker du att Single medför för villkor?

Kan du utveckla, förstår inte riktigt frågan, försöker jag tolka så
Skall en utvecklare skriva kod som bör gå snabbt i trådar, då behöver utvecklaren anpassa data för det.
Samma sak med simd, skall man få upp hastigheten måste data anpassas för det.

Har ju pratat om tabell-lösningen, tre tabeller optimerade för att fungera i trådar eller simd. Metadata ligger inte i data utan metadata ligger efter data i en egen sektion. De är specifikt skrivna för att kunna få ut offset positioner till data och att man lätt kan applicera C++ kod som tror det är ett block (med castningar) processorn kan optimera.

En av orsakerna till att första reaktionen kan kännas som "hjälp", förstår man inte orsaken är de krångliga

Skrivet av Ingetledigtnamn:

Exakt. Den specifika konstruktionen som programmeraren måste använda kallas loop. Så länge den opererar på data av samma typ, med regelbundna strides kommer kompilatorn kunna vektorisera loopen åt dig.

Men det där är inte ett realistiskt exempel, kod ser sällan ut så där. Normalt är det mer logik

Permalänk
Medlem
Skrivet av klk:

För att det bara gör diskussionen svår och tycker att om du blandar in "svåra ord" så beskriv gärna andra slipper söka. Att SIMD har sina egna register istället för de register som för vanliga operationer hade räckt. Det är inte relevant i sammanhanget vi diskuterar just nu. Det där är mer hur det är tekniskt löst i processorn.

"svåra ord"? Vad är det för svårt med dem? Vektorregister kontra skalär-register är vanlig terminologi, och skillnaden mellan vektoroperationer och skalära operationer är helt grundläggande för förståelsen av vad SIMD är och hur det fungerar. Att SIMD instruktioner jobbar mot vektor-register är väldigt relevant i sammanhanget.

Permalänk
Skrivet av klk:

Kan du utveckla, förstår inte riktigt frågan, ...

Det hela började med att du frågade

Skrivet av klk:

Förutom load, vad är det mer för villkor som måste uppfyllas för att du skall kunna köra simd-operationer?

Jag svarade

Skrivet av Ingetledigtnamn:

AVX är instruktioner som alla andra... Det är inga speciella villkor som skall vara uppfyllda...

Varpå du kontrade med

Skrivet av klk:

AVX är simd teknik och simd står för "Single instruction, multiple data", observera Single.

Då undrade jag varför du tyckte Single var viktigt och vad det medförde för villkor på när SIMD-instruktioner kan användas. Att du skrev "observera" och använde fetstil till "Single" tydde på att det var extra viktigt och att jag missat något och jag undrade vad.

Skrivet av klk:

Skall en utvecklare skriva kod som bör gå snabbt i trådar, då behöver utvecklaren anpassa data för det.
Samma sak med simd, skall man få upp hastigheten måste data anpassas för det.

Kan du utveckla lite? Själv skriver jag små enkla loopar som kompilatorn vektoriserar åt mig. Hur anpassar du data för SIMD?

Skrivet av klk:

Men det där är inte ett realistiskt exempel, kod ser sällan ut så där. Normalt är det mer logik

Tvärtom, det är ett fullständigt acceptabelt exempel på en loop som tjänar på att omvandlas till SIMD-instruktioner. Liten enkel loop, där samma operationer körs i alla loop-iterationer. Det är för sådan loopar som du tjänar mest på vektorisering.

Vill du ha mer logik så vektoriserar kompilatorn även det:

Permalänk
Medlem
Skrivet av Erik_T:

"svåra ord"? Vad är det för svårt med dem? Vektorregister kontra skalär-register är vanlig terminologi, och skillnaden mellan vektoroperationer och skalära operationer är helt grundläggande för förståelsen av vad SIMD är och hur det fungerar. Att SIMD instruktioner jobbar mot vektor-register är väldigt relevant i sammanhanget.

"Svåra ord" är ord man inte förstår, en skribent kan använda svåra ord för att det skall låta bra utan att förstå hur det som benämns svårt skall användas eller på annat sätt påverkar.

Vet du om tekniken blir det förvirrande och man undrar över argumentationen, typ, "ok, men varför säger du så".

Ja SIMD har egna register men än sedan då?

Permalänk
Medlem
Skrivet av klk:

"Svåra ord" är ord man inte förstår, en skribent kan använda svåra ord för att det skall låta bra utan att förstå hur det som benämns svårt skall användas eller på annat sätt påverkar.

Vet du om tekniken blir det förvirrande och man undrar över argumentationen, typ, "ok, men varför säger du så".

Ja SIMD har egna register men än sedan då?

Att diskutera SIMD, AVX-512 och relaterade ämnen med någon som inte känner till fundamentala begrepp som "vektorregister" är som att diskutera matematik med någon som inte förstår 'svåra' ord som "addition" eller "subtraktion", eller att diskutera ekonomi med någon som klagar på användandet av 'svåra' ord som "ränta".

Om någon inte förstår orden så är det inte för att orden i sig är svåra, utan för att personen är väldigt okunnig om ämnet. Vilket ju inte behöver vara ett problem i sig - alla kan inte vara kunniga på alla områden.

Permalänk
Medlem
Skrivet av Erik_T:

Att diskutera SIMD, AVX-512 och relaterade ämnen med någon som inte känner till fundamentala begrepp som "vektorregister" är som att diskutera matematik med någon som inte förstår 'svåra' ord som "addition" eller "subtraktion", eller att diskutera ekonomi med någon som klagar på användandet av 'svåra' ord som "ränta".

Då har jag missat, vad är det för viktigt med att veta att det finns vektorregister?

Permalänk
Datavetare
Skrivet av klk:

AVX är simd teknik och simd står för "Single instruction, multiple data", observera Single. Om det inte varit för single utan att man kunnat göra nära nog vad som helst sett till de operationer en processor har så då jäsiken, då hade simd varit otroligt effektivt.

Nu är det inte så användbart mer än i ganska speciella situationer och problemet är att det behöver vara EN och samma instruktion.
Programmerare måste skriva koden specifikt för det här.

SIMD kan ge en mycket fin prestandaboost, men tyvärr har vi hittills inte fått något riktigt bra stöd för att effektivt uttrycka logiken på ett sätt som gör att kompilatorer kan utnyttja SIMD på ett konsekvent och kraftfullt sätt.

Nvidia knäckte problemet för GPGPU med CUDA. Man kan i någon mening se en Nvidia-GPU som motsvarande ungefär AVX-1024, eftersom deras hårdvara kör 32 samtidiga lanes. Deras GPU:er är dock lite mer flexibla än ren SIMD. Nvidia kallar det SIMT (Single Instruction Multiple Threads).

Jag har skrivit det tidigare i tråden: Intel har under lång tid arbetat hårt med att försöka lösa autovektorisering, dvs. att låta kompilatorn ta ”vanlig” C/C++ (eller andra språk) och automatiskt utnyttja SIMD på ett effektivt sätt. De har dock aldrig riktigt lyckats. På det stora hela är det SIMD-stöd man får via autovektorisering väldigt bräckligt. Det kan fungera för en viss kodsekvens, medan en snarlik sekvens inte alls vektoriseras.

Det Intel fått fram är en dialekt av C/C++ som likt Nvidias CUDA är väldigt effektivt på att utnyttja SIMD. SPMD och tillhörande ISPC-kompilator.

Vill man ha bra utväxling får man antingen använda något i stil med SPMD (som i nuläget stödjer x86_64 och ARM64) eller använda intrinsics (eller assembler). Det senare ger full åtkomst, men tyvärr blir det inte alls portabelt.

Både C++ och Rust arbetar på att få in explicit SIMD-stöd i standardbiblioteken. Jag har använt det lite sparsamt i Rust. Fördelen här är att koden kommer fungera på alla CPU:er, även de som saknar SIMD-stöd (logiskt sett sker samma sak, men man får ingen acceleration på en CPU utan SIMD).

Eftersom det ska fungera ”överallt” blir det inte lika flexibelt som intrinsics eller SPMD/CUDA, men det bör ändå ge betydligt bättre stöd än vad vi idag får från autovektorisering.

#![feature(portable_simd)] use std::simd::{LaneCount, Simd, StdFloat, SupportedLaneCount}; // Structure of Arrays (SoA) for points (x, y, z, w) #[derive(Debug)] struct Points { x: Vec<f32>, y: Vec<f32>, z: Vec<f32>, w: Vec<f32>, } impl Points { fn new(n: usize) -> Self { Self { x: vec![0.0; n], y: vec![0.0; n], z: vec![0.0; n], w: vec![0.0; n], } } fn len(&self) -> usize { self.x.len() } } // Apply a 4×4 *row-major* transform to all points in-place: // [x', y', z', w']^T = M * [x, y, z, w]^T // // Works on any SIMD width supported by the platform (e.g., 4, 8, 16 lanes). fn transform_points<const LANES: usize>(pts: &mut Points, m: [[f32; 4]; 4]) where LaneCount<LANES>: SupportedLaneCount, { let n = pts.len(); assert_eq!(pts.y.len(), n); assert_eq!(pts.z.len(), n); assert_eq!(pts.w.len(), n); // Broadcast matrix rows’ coefficients let m00 = Simd::<f32, LANES>::splat(m[0][0]); let m01 = Simd::<f32, LANES>::splat(m[0][1]); let m02 = Simd::<f32, LANES>::splat(m[0][2]); let m03 = Simd::<f32, LANES>::splat(m[0][3]); let m10 = Simd::<f32, LANES>::splat(m[1][0]); let m11 = Simd::<f32, LANES>::splat(m[1][1]); let m12 = Simd::<f32, LANES>::splat(m[1][2]); let m13 = Simd::<f32, LANES>::splat(m[1][3]); let m20 = Simd::<f32, LANES>::splat(m[2][0]); let m21 = Simd::<f32, LANES>::splat(m[2][1]); let m22 = Simd::<f32, LANES>::splat(m[2][2]); let m23 = Simd::<f32, LANES>::splat(m[2][3]); let m30 = Simd::<f32, LANES>::splat(m[3][0]); let m31 = Simd::<f32, LANES>::splat(m[3][1]); let m32 = Simd::<f32, LANES>::splat(m[3][2]); let m33 = Simd::<f32, LANES>::splat(m[3][3]); // Process full SIMD chunks let simd_chunks = n / LANES; for chunk in 0..simd_chunks { let i = chunk * LANES; // Load SoA lanes let vx = Simd::<f32, LANES>::from_slice(&pts.x[i..i + LANES]); let vy = Simd::<f32, LANES>::from_slice(&pts.y[i..i + LANES]); let vz = Simd::<f32, LANES>::from_slice(&pts.z[i..i + LANES]); let vw = Simd::<f32, LANES>::from_slice(&pts.w[i..i + LANES]); // Row 0: x' = m00*x + m01*y + m02*z + m03*w let x_prime = vx.mul_add(m00, vy.mul_add(m01, vz.mul_add(m02, vw * m03))); // Row 1: y' let y_prime = vx.mul_add(m10, vy.mul_add(m11, vz.mul_add(m12, vw * m13))); // Row 2: z' let z_prime = vx.mul_add(m20, vy.mul_add(m21, vz.mul_add(m22, vw * m23))); // Row 3: w' let w_prime = vx.mul_add(m30, vy.mul_add(m31, vz.mul_add(m32, vw * m33))); // Store back x_prime.copy_to_slice(&mut pts.x[i..i + LANES]); y_prime.copy_to_slice(&mut pts.y[i..i + LANES]); z_prime.copy_to_slice(&mut pts.z[i..i + LANES]); w_prime.copy_to_slice(&mut pts.w[i..i + LANES]); } // Scalar tail (if n is not a multiple of LANES) let tail_start = simd_chunks * LANES; for i in tail_start..n { let x = pts.x[i]; let y = pts.y[i]; let z = pts.z[i]; let w = pts.w[i]; let x_p = m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3] * w; let y_p = m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3] * w; let z_p = m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3] * w; let w_p = m[3][0] * x + m[3][1] * y + m[3][2] * z + m[3][3] * w; pts.x[i] = x_p; pts.y[i] = y_p; pts.z[i] = z_p; pts.w[i] = w_p; } } fn main() { // Choose a SIMD width supported on your target. 8 or 16 is typically a good default on desktops. const LANES: usize = 16; // Make some points let n = 1000; let mut pts = Points::new(n); for i in 0..n { pts.x[i] = i as f32; pts.y[i] = i as f32 + 1.0; pts.z[i] = i as f32 + 2.0; pts.w[i] = 1.0; } // Example matrix (identity) let m = [ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ]; transform_points::<LANES>(&mut pts, m); // Print a couple of results for i in 0..3 { println!("({:.1}, {:.1}, {:.1}, {:.1})", pts.x[i], pts.y[i], pts.z[i], pts.w[i]); } }

Ger bra användning av SIMD både på ARM64

och x86_64

Grunden i ett enkelt partikel-system skulle kunna innehåller något likt detta
Ett "inte" hade fallit bort
Visa signatur

Care About Your Craft: Why spend your life developing software unless you care about doing it well? - The Pragmatic Programmer

Permalänk
Medlem

Nu har du redan fått bra svar från @Ingetledigtnamn så det finns ingen poäng med att upprepar samma saker.

Skrivet av klk:

Förutom load, vad är det mer för villkor som måste uppfyllas för att du skall kunna köra simd-operationer?

Om vi tar ett exempel, en funktion som fyller alla positioner i en 32 bitars heltals array med samma värde. En del av den vektoriserade inre loopen skulle kunna se ut så här:

… vpbroadcastd zmm0, esi vmovdqu64 ZMMWORD PTR [rdi + 4*rcx], zmm0 vmovdqu64 ZMMWORD PTR [rdi + 4*rcx + 64] zmm0 vmovdqu64 ZMMWORD PTR [rdi + 4*rcx + 128], zmm0 vmovdqu64 ZMMWORD PTR [rdi + 4*rcx + 192], zmm0 add rcx, 64 …

vpbroadcastd - fyller ett jätte-många-värden-register med värdet från ett bara-ett-värde-åt-gången-register.

På vilket sätt menar du att det finns "villkor" som måste uppfyllas för att köra vpbroadcastd jämfört med add instruktionen? (förutom självklarheter som att processorn måste ha stöd för instruktionen)

Skrivet av klk:

ok. du tror det. Så vad är simd-vänligt enligt dig?

Skulle du kunna visa exempel på kod där simd fungerar bra

Det här har tagits upp tidigare och då fick du exempelkod på hur det skulle kunna se ut och jag bad dig även visa hur du skulle skriva motsvarade kod med din version av tagged-unions. Men det var för "jobbigt"?

Här är samma kod igen i simd utförande.

final case class Circles(radii: Array[Double]) final case class Rectangles(widths: Array[Double], heights: Array[Double]) final case class ShapeData(circles: Circles, rectangles: Rectangles) def totalArea(data: ShapeData): Double = val isp = DoubleVector.SPECIES_PREFERRED val radii = data.circles.radii var area = DoubleVector.zero(isp) var idx = 0 while idx < isp.loopBound(radii.length) do val rv = DoubleVector.fromArray(isp, radii, idx) area = area.add(rv.mul(rv).mul(Math.PI)) idx += isp.length if idx < radii.length then val mask = isp.indexInRange(idx, radii.length) val rv = DoubleVector.fromArray(isp, radii, idx, mask) area = area.add(rv.mul(rv).mul(Math.PI)) val widths = data.rectangles.widths val heights = data.rectangles.heights idx = 0 while idx < isp.loopBound(widths.length) do val wv = DoubleVector.fromArray(isp, widths, idx) val hv = DoubleVector.fromArray(isp, heights, idx) area = area.add(wv.mul(hv)) idx += isp.length if idx < widths.length then val mask = isp.indexInRange(idx, widths.length) val wv = DoubleVector.fromArray(isp, widths, idx, mask) val hv = DoubleVector.fromArray(isp, heights, idx, mask) area = area.add(wv.mul(hv)) area.reduceLanes(VectorOperators.ADD)

Dold text

Mitt förslag är att du skriver något liknande väldigt enkelt exempel med din version av tagged-unions så kan vi titta på den koden tillsammans, vilken assembler den genererar, minneslayout osv. Jag tror du skulle hänga med bättre då.

Permalänk
Medlem
Skrivet av Yoshman:

Både C++ och Rust jobbar på att få in explicit SIMD-stöd i standard-biblioteken. Har använt det lite sparsamt med Rust. Fördelen här är att koden kommer fungera på alla CPUer, även de som saknar SIMD-stöd (logiskt sett sker samma sak, men man får ingen acceleration på en CPU som saknar SIMD).

Just då det ska fungera "överallt" kommer det bli lika flexibelt som intrinsics eller SPMD/CUDA, men det bör ändå ge långt bättre stöd än vad vi idag får från autovektorinsering.

Jag tycker att C++ simd stödet ser extremt begränsat ut? Jag hittar inget om shuffle/permute t.ex.?

Det var jag som inte sökte ordentligt. Det ser ut att finnas ett förslag om utökning med permute funktioner redan P2664R9.

Permalänk

För att återknyta till tråden ursprungliga ämne så föredrar standardkommittén Profiles framför Safe C++.

Permalänk
Datavetare
Skrivet av jclr:

Jag tycker att C++ simd stödet ser extremt begränsat ut? Jag hittar inget om shuffle/permute t.ex.?

Det var jag som inte sökte ordentligt. Det ser ut att finnas ett förslag om utökning med permute funktioner redan P2664R9.

Varken C++ eller Rust varianterna gör ju inga anspråk på att vara en ersättare för intrinsics/CUDA/SPMD varianter. Huvudmålet verkar ju vara att kunna utnyttja SIMD betydligt bättre än idag, samtidigt som man kan göra det på ett sätt som är helt portabelt.

Har inte följt C++-jobbet alls egentligen, mer än jag känner till att det finns. Rust-varianten har funnits i en relativt användbar variant i ca 5 år nu, det som ser att vara den stora stötestenen för att ta in det som en stabil funktion är hur man ska hantera att det finns så väldigt stor variation kring fysisks bred på SIMD-implementationerna.

En del tycker att det vettiga för en "portabel SIMD" är att man i programmet specificerar en fix bredd, fast vad gör man på HW som inte stödjer bredden (går i.o.f.s. alltid att emulera, men kan ju ge oönskade prestandaeffekter) och väljer man något nästan alla klarar av, säg 128-bit bredd, så utnyttjas ju inte bredare varianter alls optimalt (tydligen finns en RISC-V med 16384 bitar i det projekt EU finansierar).

Andra verkar tycka att "portabelt" borde betyda att man väljer det som är "native-bredd" på respektive plattform. Bra för HW, svårare att hantera i sina program. Eller beror väl på problemet, SOA-approach:en kan väl ofta hantera detta. Exemplet jag gjorde ovan fixar det. Men i andra fall, finns kommentarer från folk som jobbar med ljud/signalhantering, så sägs det bli rätt svårt att jobba med en icke-fix SIMD-bredd.

En snabb genomläsning av std::simd ser ut att gå på spåret "försök utnyttja den faktiskt SIMD-bredden på HW". Men på det stora hela känns std::simd scope ännu mindre än motsvarande i Rust, som inte heller gör anspråk på att täcka allt utan fokus är på ett "portabelt subset av SIMD".

Skrivet av Ingetledigtnamn:

För att återknyta till tråden ursprungliga ämne så föredrar standardkommittén Profiles framför Safe C++.

Kanske inte oväntat och även om Safe C++ faktiskt ger långt bättre garantier än vad Profiles lär kunna ge känns kritiken mot Safe C++ rimlig: det förändrar sättet man behöver skriva C++ så pass mycket att det nästan blir ett annan språk. Det är rätt mycket Rust med C++ syntax. I det läget, varför inte bara köra med Rust?

Får se hur det går med TrapC. Det ger på flera sätt liknande garantier jämfört med Safe C++, men det ser fortfarande ut som "vanlig" C. Stora potentiella problemet med TrapC är att det inte är 100 % kompatibelt med existerande C kod, även om det mesta kan stödja TrapC bara genom en omkompilering.

Visa signatur

Care About Your Craft: Why spend your life developing software unless you care about doing it well? - The Pragmatic Programmer

Permalänk
Medlem
Skrivet av jclr:

På vilket sätt menar du att det finns "villkor" som måste uppfyllas för att köra vpbroadcastd jämfört med add instruktionen? (förutom självklarheter som att processorn måste ha stöd för instruktionen)

Sitter dåligt till för att svara men tänkte bara nämna kort.

Det är svårt att komma på bra exempel där koden kan optimeras med SIMD. Att de exemplen som tagits upp här är av mycket simpel funktionalitet säger väl en hel del?
De är så enkla att det knappt existerar för om det inte handlar om lätta saker som att söka i data och då enkel sökning eller det mest enkla matematiska operationerna bli det genast mycket svårare att göra bra exempel.

Vid beräkningskod och man räkna på mycket så är det en hel del kod som kan optimeras med sådant här men eftersom det är olika typer av beräkningar för varje "data-del" blir det att skriva om kod. Det är kanske matrisvärden som ligger i en tabell. Tusentals värden och skall samma operation göras på varje värde i matrisen är det lätt, men om det är olika operationer blir det genast mycket svårare.
Och när värden i matrisen är beroende av andra värden i matrisen är heller inget som förenklar.

Permalänk
Skrivet av klk:

Det är svårt att komma på bra exempel där koden kan optimeras med SIMD. Att de exemplen som tagits upp här är av mycket simpel funktionalitet säger väl en hel del?
De är så enkla att det knappt existerar för om det inte handlar om lätta saker som att söka i data och då enkel sökning eller det mest enkla matematiska operationerna bli det genast mycket svårare att göra bra exempel.

Vad har du för förväntningar på när SIMD ska kunna användas? Hela idén bygger ju på att man skall dra nytta av att man gör samma sak gång efter gång och istället göra samma sak på flera element parallellt och kunna köra färre iterationer genom loopen. Har du generell kod med komplex logik så är det inte samma sak som skall göras för alla element. Då passar koden inte lika väl för SIMD.

Men vill du ha mer komplex SIMD-kod kan vi ordna detta. Jag och @jclr debatterade om och hur man kunde/borde göra en SIMD-lösning till Advent of Code 2024, dag 2 Uppgiften beskrivs här. Min optimerade SIMD-lösning ser ut så här. Det här är dock inget som kompilatorn kommer göra automatiskt åt dig.

Permalänk
Medlem
Skrivet av Ingetledigtnamn:

Vad har du för förväntningar på när SIMD ska kunna användas?

Min senaste argumentation handlade om hur svårt det var att få nytta av AVX512 och kritiserades för det. Därav förklarade jag att det inte BARA handlade om att få in data till cache. Fick då som svar att det inte är så svårt att få in data i cache vilket jag håller med om om man jämför med hur svårt det är att dra nytta av att processorn kan utföra 8/16 operationer samtidigt. Skriva kod som gör det är mycket svårt

Så för att svara, jag har inga förväntningar på AVX512, Utan det du frågar om var precis det jag försökte förklara.

Är medveten om att så fort jag skriver något tycks en del med automatik tycka tvärtom

Permalänk
Medlem
Skrivet av klk:

Är medveten om att så fort jag skriver något tycks en del med automatik tycka tvärtom

Eller så säger du en rad uppenbart felaktiga påstående och folk försöker hjälpa dig på rätt bana men du envisas med att fortsätta ha fel istället för att ta emot hjälpen.

Permalänk
Skrivet av Yoshman:

En snabb genomläsning av std::simd ser ut att gå på spåret "försök utnyttja den faktiskt SIMD-bredden på HW". Men på det stora hela känns std::simd scope ännu mindre än motsvarande i Rust, som inte heller gör anspråk på att täcka allt utan fokus är på ett "portabelt subset av SIMD".

Man måste fråga sig vilken målgrupp man siktar på.

Att göra så gott man kan med native-bredden känns som det enda vettiga alternativet. Om jag tillhörde den lilla klick som tog sig omaket att skriva sin kod med hjälp av SIMD-intrinsics för att krama sista droppen av compute ur CPU:n och det kom en "SIMD"-lösning som körde 128-bitar brett för att det var den största gemensamma bredden som alla kunde stödja, då skulle jag vara ganska missnöjd. Om man offrar prestanda för portabilitet kommer de som verkligen behöver std::simd inte kunna använda det.

Permalänk
Datavetare
Skrivet av Ingetledigtnamn:

Man måste fråga sig vilken målgrupp man siktar på.

Att göra så gott man kan med native-bredden känns som det enda vettiga alternativet. Om jag tillhörde den lilla klick som tog sig omaket att skriva sin kod med hjälp av SIMD-intrinsics för att krama sista droppen av compute ur CPU:n och det kom en "SIMD"-lösning som körde 128-bitar brett för att det var den största gemensamma bredden som alla kunde stödja, då skulle jag vara ganska missnöjd. Om man offrar prestanda för portabilitet kommer de som verkligen behöver std::simd inte kunna använda det.

Är kanske en viktig orsak till varför det går relativt trögt framåt med "portable SIMD", målgruppen kan mycket väl vara rätt liten/nischad.

Råder inget tvivel om att SIMD har stort värde inom vissa områden. Men i många fall finns ramverk/bibliotek som under huven har väldigt bra utnyttjade av tekniken, men utan att man som användare egentligen tänker speciellt mycket på det. T.ex. NumPy, PyTorch, Havok Physics och simdjson.

Har man sedan andra mer nischade use-case så finns ju intrinsics, SPMD och även SyCL (som stödjer både CPU/SIMD och GPGPU).

Det som blir kvar kanske är rätt smalt... För även med portable SIMD i C++/Rust blir det en tröskel, så bara värt det om man faktiskt undanröjer relevanta flaskhalsar. Det samtidigt som portable SIMD nog aldrig helt kommer matcha mer specialiserade lösning.

Så kommer tillbaka till att "bättre autovektorisering" är väldigt önskvärt då det blir relativt gratis för användaren. Men SIMD är tillräckligt annorlunda för att ge en hel del utmaningar till riktigt bra autovektorisering.

Ett annat väldigt bra sätt att få fin utväxling på förbättringar kring SIMD är bibliotek som Apples Accelerate. Där har man funktioner på väldigt låg nivå, men fortfarande en nivå upp från att direkt skriva intrinsics eller motsvarande. Att bibliotek som t.ex. NumPy och PyTorch är implementerade ovanpå Accelerate har ju gjort att man direkt fick utväxling på funktioner som AMX (icke-standard funktioner i M1-M3) och en sömnlös övergång till SME2 (standardfunktion som nu finns i flera ARM64 CPUer, inklusive M4 och framåt). Under Intel eran användes naturligtvis SSE/AVX i Accelerate.

Så känner inte själv att det brinner i knutarna att få portable SIMD, försöker välja bibliotek som redan har SIMD-opt om det är relevant. Kul om det kommer, följer utvecklingen hos Rust-gänget. Men går rätt bra utan också...

Visa signatur

Care About Your Craft: Why spend your life developing software unless you care about doing it well? - The Pragmatic Programmer

Permalänk
Datavetare
Skrivet av klk:

Min senaste argumentation handlade om hur svårt det var att få nytta av AVX512 och kritiserades för det. Därav förklarade jag att det inte BARA handlade om att få in data till cache. Fick då som svar att det inte är så svårt att få in data i cache vilket jag håller med om om man jämför med hur svårt det är att dra nytta av att processorn kan utföra 8/16 operationer samtidigt. Skriva kod som gör det är mycket svårt

Så för att svara, jag har inga förväntningar på AVX512, Utan det du frågar om var precis det jag försökte förklara.

Är medveten om att så fort jag skriver något tycks en del med automatik tycka tvärtom

Om det är så svårt att nyttja AVX512, vad tror du då om de som jobbar med en CPU med 16384 bitars SIMD. De kommer behöva alien-tech cache

Eller så kanske bra/effekt skrivna SIMD-algoritmer är rätt mycket "best-case" för moderna CPUers automatiska prefetchers...

Visa signatur

Care About Your Craft: Why spend your life developing software unless you care about doing it well? - The Pragmatic Programmer

Permalänk
Medlem
Skrivet av Yoshman:

Varken C++ eller Rust varianterna gör ju inga anspråk på att vara en ersättare för intrinsics/CUDA/SPMD varianter. Huvudmålet verkar ju vara att kunna utnyttja SIMD betydligt bättre än idag, samtidigt som man kan göra det på ett sätt som är helt portabelt.

Har inte följt C++-jobbet alls egentligen, mer än jag känner till att det finns. Rust-varianten har funnits i en relativt användbar variant i ca 5 år nu, det som ser att vara den stora stötestenen för att ta in det som en stabil funktion är hur man ska hantera att det finns så väldigt stor variation kring fysisks bred på SIMD-implementationerna.

En del tycker att det vettiga för en "portabel SIMD" är att man i programmet specificerar en fix bredd, fast vad gör man på HW som inte stödjer bredden (går i.o.f.s. alltid att emulera, men kan ju ge oönskade prestandaeffekter) och väljer man något nästan alla klarar av, säg 128-bit bredd, så utnyttjas ju inte bredare varianter alls optimalt (tydligen finns en RISC-V med 16384 bitar i det projekt EU finansierar).

Andra verkar tycka att "portabelt" borde betyda att man väljer det som är "native-bredd" på respektive plattform. Bra för HW, svårare att hantera i sina program. Eller beror väl på problemet, SOA-approach:en kan väl ofta hantera detta. Exemplet jag gjorde ovan fixar det. Men i andra fall, finns kommentarer från folk som jobbar med ljud/signalhantering, så sägs det bli rätt svårt att jobba med en icke-fix SIMD-bredd.

En snabb genomläsning av std::simd ser ut att gå på spåret "försök utnyttja den faktiskt SIMD-bredden på HW". Men på det stora hela känns std::simd scope ännu mindre än motsvarande i Rust, som inte heller gör anspråk på att täcka allt utan fokus är på ett "portabelt subset av SIMD".

Ja allt som gör SIMD programmering mer tillgängligt är ett stort steg i rätt riktning och när det gäller C++ / Rust så finns ju intrinsics som escape-hatch om något saknas. Jag tittade först bara på <simd> och då såg det väldigt begränsat ut.

Jag gillar den flexibla lösningen JVM/Java har valt för hantering av vektorlängd där man kan välja att antingen jobba med en fast längd eller låta JVMen välja utifrån hårdvaran. Precis som du skriver så finns det fördelar/nackdelar med båda valen och det finns inget självklart bästa antingen-eller-val.

Permalänk
Medlem
Skrivet av klk:

Det är svårt att komma på bra exempel där koden kan optimeras med SIMD. Att de exemplen som tagits upp här är av mycket simpel funktionalitet säger väl en hel del?
De är så enkla att det knappt existerar för om det inte handlar om lätta saker som att söka i data och då enkel sökning eller det mest enkla matematiska operationerna bli det genast mycket svårare att göra bra exempel.

Jag väljer enkla exempel som räcker för att visa det jag vill visa. Poängen är att det ska vara möjligt att hänga med vad koden faktiskt gör, inte att visa upp komplex kod. Du har själv skrivit att det är förvirrande med "svåra ord" som vektorregister och då finns det väl en poäng med att vi undviker allt för komplexa exempel?

Skrivet av Ingetledigtnamn:

Men vill du ha mer komplex SIMD-kod kan vi ordna detta. Jag och @jclr debatterade om och hur man kunde/borde göra en SIMD-lösning till Advent of Code 2024, dag 2 Uppgiften beskrivs här. Min optimerade SIMD-lösning ser ut så här. Det här är dock inget som kompilatorn kommer göra automatiskt åt dig.

Det handlade väl egentligen mest om att vi valde olika startpositioner. Om man kan anpassa datan först för bättre simd layout eller inte. Men det är ett bra exempel på något mer komplicerad kod.

Här @kik har du ett till exempel på hur man kan lösa samma problem. Den här versionen gör själva beräkningarna långsammare men förutsätter inte perfekt datalayout för SIMD. Den innehåller också ett exempel på hur man kan parsa själva texten med hjälp av AVX512.

Naturligtvis skulle man kunna använda SIMD även för parsning med den kod @Ingetledigtnamn postade med då behöver man också göra en 64×64 byte transpose mellan parsning och beräkning.

def parseBytesSimd(input: Array[Byte], output: Array[Byte]) = val bsp = ByteVector.SPECIES_512 val writeMask = VectorMask.fromLong(bsp, 0x0000_0000_0000_ffffL) var readIdx = 0 var writeIdx = 0 while readIdx < input.length - 64 do val chars = ByteVector.fromArray(bsp, input, readIdx) val maskNumbers = chars.compare(GE, 48) val maskNewLines = chars.compare(EQ, 10) val maskSpaces = chars.compare(EQ, 32) val maskSeparators = maskNewLines.or(maskSpaces) val maskOnes = jl.Long.rotateRight(maskSeparators.toLong, 1) val maskTens = maskNumbers.and(jl.Long.rotateRight(maskSeparators.toLong, 2)) val digits = chars.sub(48: Byte) val ones = digits val tens = ByteVector.broadcast(bsp, 0).blend(digits, maskTens) val tensShifted = tens.unslice(1).reinterpretAsShorts.mul(10: Short).reinterpretAsBytes val nums = ones.add(tensShifted) val numsCompressed = nums.compress(maskOnes) val firstNewLine = maskNewLines & -maskNewLines val firstMask = firstNewLine - 1 val firstPart = jl.Long.compress(-1L, firstMask & maskOnes) val secondNewLine = (maskNewLines ^ firstNewLine) & -(maskNewLines ^ firstNewLine) val secondPart = jl.Long.compress(-1L, (secondNewLine - 1) & ~firstMask & maskOnes) << 8L numsCompressed.expand(firstPart | secondPart).intoArray(output, writeIdx, writeMask) readIdx += jl.Long.numberOfTrailingZeros(secondNewLine) + 1 writeIdx += 16 def part1(data: Array[Byte]) = val bsp = ByteVector.SPECIES_512 val lsp = LongVector.SPECIES_512 val shift = VectorShuffle.iota(bsp, 1, 1, false) val m1 = VectorMask.fromLong(bsp, 0x7f7f_7f7f_7f7f_7f7fL) var count = 0 var idx = 0 while idx < bsp.loopBound(data.length) do val bv = ByteVector.fromArray(bsp, data, idx) val lens = lsp.broadcast(7).sub(bv.reinterpretAsLongs.lanewise(LEADING_ZEROS_COUNT).lanewise(LSHR, 3)) val shifted = bv.rearrange(shift) val m2 = shifted.compare(NE, 0) val m = m1.and(m2) val diff = bv.sub(shifted) val bits = lens.mul(lsp.broadcast(8)) val cmp1 = diff.compare(GE, 1, m).and(diff.compare(LE, 3, m)).toVector val cmp2 = diff.compare(GE, -3, m).and(diff.compare(LE, -1, m)).toVector count += cmp1.reinterpretAsLongs.lanewise(BIT_COUNT).eq(bits).trueCount count += cmp2.reinterpretAsLongs.lanewise(BIT_COUNT).eq(bits).trueCount idx += bsp. def part2(data: Array[Byte]) = val bsp = ByteVector.SPECIES_512 val lsp = LongVector.SPECIES_512 val shift = VectorShuffle.iota(bsp, 1, 1, false) val m1 = VectorMask.fromLong(bsp, 0x7f7f_7f7f_7f7f_7f7fL) val shuffle = VectorShuffle.fromArray(bsp, shuffleArray, 0) val loadMask = bsp.indexInRange(0, 8) var count = 0 var idx = 0 while idx < bsp.loopBound(data.length) do val bv = ByteVector.fromArray(bsp, data, idx, loadMask) val dist = bv.rearrange(shuffle) val lens = lsp.broadcast(7).sub(dist.reinterpretAsLongs.lanewise(LEADING_ZEROS_COUNT).lanewise(LSHR, 3)) val shifted = dist.rearrange(shift) val m2 = shifted.compare(NE, 0) val m = m1.and(m2) val diff = dist.sub(shifted) val cmp1 = diff.compare(GE, 1, m).and(diff.compare(LE, 3, m)).toVector val cmp2 = diff.compare(GE, -3, m).and(diff.compare(LE, -1, m)).toVector val bits = lens.mul(lsp.broadcast(8)) if cmp1.reinterpretAsLongs .lanewise(BIT_COUNT) .eq(bits) .or(cmp2.reinterpretAsLongs.lanewise(BIT_COUNT).eq(bits)) .anyTrue() then count += 1 idx += 8 count

Dold text
Skrivet av klk:

Jag har ju åkt på massiv kritik för att skriva optimerad kod, bl.a. kod som passar för avx.

Det är ingen som har kritiserat dig för att skriva kod optimerad för avx. Det handlar inte heller om att vi automatisk måste tycka tvärtom. Ingen av den kod eller beskrivningar av kod du har postad hittills är optimerad för SIMD utan precis tvärtom, det är kod som är helt omöjlig att autovektorisera.

Permalänk
Medlem
Skrivet av jclr:

Här @kik har du ett till exempel på hur man kan lösa samma problem. Den här versionen gör själva beräkningarna långsammare men förutsätter inte perfekt datalayout för SIMD. Den innehåller också ett exempel på hur man kan parsa själva texten med hjälp av AVX512.

Kort svar, skall svara mer ikväll

Tolkar jag koden rätt om det är en enklare parser? Parsa text där varje tecken har samma storlek är det område där AVX fungerar bäst och är lättast att fixa koden med. Även om det är enklare uppgifter blir koden ändå svår.

Gör samma sak som koden ovan men med UTF8 text trots att det fortfarande "bara" handlar om att parsa text så blir koden mycket svår och dessutom svårare att dra nytta av AVX.

Vill du se på "kul" kod så ladda ner koden för SIMD json ( https://github.com/simdjson/simdjson ). Den har de gått loss ordentligt på och optimerat.

Det låter på en del i tråden att det går att göra lite vad som helst med SIMD och få upp snabbhet, det är det jag vänder mig emot för det stämmer inte.

Permalänk
Medlem
Skrivet av klk:

Tolkar jag koden rätt om det är en enklare parser? Parsa text där varje tecken har samma storlek är det område där AVX fungerar bäst och är lättast att fixa koden med. Även om det är enklare uppgifter blir koden ändå svår.

Ja, precis som jag skrev så är det exempel på hur man kan använda AVX512 för parsning.

Beskrivningen visar bara en förenklad version av formatet. Man måste vara inloggad på AoC för att kunna ladda ner sin egen input till pusslet.

Det riktiga formatet ser ut så här:

30 24 21 19 18 17 14 14 60 55 50 48 47 45 4 6 9 9 9 10 7 10 12 12 13 17 …

Varje rad innehåller 5-8 nummer. Varje nummer kan bestå av en eller två siffror (bytes). Totalt 10-25 bytes per rad.

Koden parsar två hela rader åt gången till två grupper om 8 bytes.

Hade formatet varit enklare som t.ex. 8 enkla siffror per rad så hade det i C++ räckt med out[idx] = in[idx * 2] - '0'; vilket är ett mönster clang/gcc klarar av att autovektorisera.

Nu har du iaf fått ett par lite mer komplexa exempel eftersom du frågade efter det. Hur gör du när du skriver kod optimerad för avx?

Permalänk
Medlem
Skrivet av jclr:

Nu har du iaf fått ett par lite mer komplexa exempel eftersom du frågade efter det. Hur gör du när du skriver kod optimerad för avx?

Får svara på den här i omgångar för precis som du beskrivit är det inte lätt att beskriva avx med enkel kod. Passar då på i exempel ett och göra en simd vänlig lösning med tabell objekt

TEST_CASE("[table] simd table 01", "[table]") { using namespace gd::table::dto; constexpr unsigned uRowCount = 10; // create table with 10 double columns, 8 value columns and two sum columns table tableSimd( uRowCount, 0, 0, gd::types::type_g( "double" ), 10, gd::table::tag_prepare{} ); for( int i = 0; i < uRowCount; ++i ) { // add numbers to each column auto uRow = tableSimd.row_add_one(); for( int j = 0; j < 8; ++j ) { tableSimd.cell_set( uRow, j, (double)(i * 10 + j) ); } } // calculate sum of each row for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; for( int j = 0; j < 8; ++j ) { dSum += tableSimd.cell_get_variant_view(i, j).as_double(); } tableSimd.cell_set( i, 8, dSum ); } // calculate using simd friendly code for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; // get pointer to 8 doubles uint8_t* puRow = tableSimd.row_get( i ); bool b8ByteAligned = (reinterpret_cast<uintptr_t>(puRow) % 8) == 0; assert( b8ByteAligned == true ); // sum using simd friendly code for( int j = 0; j < 8; ++j ) { dSum += reinterpret_cast<double*>(puRow)[j]; } double dSum2 = 0.0; double* __restrict pdCells8 = reinterpret_cast<double*>(puRow); for( int j = 0; j < 8; ++j ) { dSum2 += pdCells8[j]; } assert(dSum == dSum2); tableSimd.cell_set( i, 9, dSum ); } // print table to cli { std::string stringTable = gd::table::to_string(tableSimd, gd::table::tag_io_cli{}); std::cout << stringTable << std::endl; } }

Förklaring

Är det inte en parser som skrivs utan det handlar om världen så när jag försöker skriva simdvänlig kod är ovanstående ett typiskt exempel på strategi.
Det blir då en tvåstegs operation där det första steget är att samla upp data som skall beräknas i något som passar för simd. Det kan exempelvis göras med att minne allokeras, kanske buffer på stacken eller som i exemplet ovan, där en tabell används.

- table tableSimd( uRowCount, 0, 0, gd::types::type_g( "double" ), 10, gd::table::tag_prepare{} );
uRowCount = antal rader som skall allokeras, 0 = flaggor för tabell, 0 = om tabellen behöver växa, hur många nya rader som skall skapas, gd::types::type_g( "double" ) = kolumn typ, i detta fallet double värden, 10 = antal kolumner, här skapas tabellen med 10 double kolumner. gd::table::tag_prepare{} = tag dispatcher för att undvika konflikter och den här berättare att konstruktören gör färdig tabellen för användning.

- tabellen fylls med data, de första 8 kolumnerna

- första exemplet för att summera värden på varje rad, summan läggs i kolumn 9

- andra exemplet är kod för att kompilatorn skall kunna generera simd instruktioner. summan räknas ut och placeras i kolumn 10

- i slutet skrivs tabellen ut

+-----+-----+-----+-----+-----+-----+-----+-----+------+------+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 28 | | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 108 | 108 | | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 188 | 188 | | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 268 | 268 | | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 348 | 348 | | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 428 | 428 | | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 508 | 508 | | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 588 | 588 | | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 668 | 668 | | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 748 | 748 | +-----+-----+-----+-----+-----+-----+-----+-----+------+------+

Permalänk
Medlem
Skrivet av klk:

Får svara på den här i omgångar för precis som du beskrivit är det inte lätt att beskriva avx med enkel kod. Passar då på i exempel ett och göra en simd vänlig lösning med tabell objekt

TEST_CASE("[table] simd table 01", "[table]") { using namespace gd::table::dto; constexpr unsigned uRowCount = 10; // create table with 10 double columns, 8 value columns and two sum columns table tableSimd( uRowCount, 0, 0, gd::types::type_g( "double" ), 10, gd::table::tag_prepare{} ); for( int i = 0; i < uRowCount; ++i ) { // add numbers to each column auto uRow = tableSimd.row_add_one(); for( int j = 0; j < 8; ++j ) { tableSimd.cell_set( uRow, j, (double)(i * 10 + j) ); } } // calculate sum of each row for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; for( int j = 0; j < 8; ++j ) { dSum += tableSimd.cell_get_variant_view(i, j).as_double(); } tableSimd.cell_set( i, 8, dSum ); } // calculate using simd friendly code for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; // get pointer to 8 doubles uint8_t* puRow = tableSimd.row_get( i ); bool b8ByteAligned = (reinterpret_cast<uintptr_t>(puRow) % 8) == 0; assert( b8ByteAligned == true ); // sum using simd friendly code for( int j = 0; j < 8; ++j ) { dSum += reinterpret_cast<double*>(puRow)[j]; } double dSum2 = 0.0; double* __restrict pdCells8 = reinterpret_cast<double*>(puRow); for( int j = 0; j < 8; ++j ) { dSum2 += pdCells8[j]; } assert(dSum == dSum2); tableSimd.cell_set( i, 9, dSum ); } // print table to cli { std::string stringTable = gd::table::to_string(tableSimd, gd::table::tag_io_cli{}); std::cout << stringTable << std::endl; } }

Förklaring

Är det inte en parser som skrivs utan det handlar om världen så när jag försöker skriva simdvänlig kod är ovanstående ett typiskt exempel på strategi.
Det blir då en tvåstegs operation där det första steget är att samla upp data som skall beräknas i något som passar för simd. Det kan exempelvis göras med att minne allokeras, kanske buffer på stacken eller som i exemplet ovan, där en tabell används.

- table tableSimd( uRowCount, 0, 0, gd::types::type_g( "double" ), 10, gd::table::tag_prepare{} );
uRowCount = antal rader som skall allokeras, 0 = flaggor för tabell, 0 = om tabellen behöver växa, hur många nya rader som skall skapas, gd::types::type_g( "double" ) = kolumn typ, i detta fallet double värden, 10 = antal kolumner, här skapas tabellen med 10 double kolumner. gd::table::tag_prepare{} = tag dispatcher för att undvika konflikter och den här berättare att konstruktören gör färdig tabellen för användning.

- tabellen fylls med data, de första 8 kolumnerna

- första exemplet för att summera värden på varje rad, summan läggs i kolumn 9

- andra exemplet är kod för att kompilatorn skall kunna generera simd instruktioner. summan räknas ut och placeras i kolumn 10

- i slutet skrivs tabellen ut

+-----+-----+-----+-----+-----+-----+-----+-----+------+------+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 28 | | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 108 | 108 | | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 188 | 188 | | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 268 | 268 | | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 348 | 348 | | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 428 | 428 | | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 508 | 508 | | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 588 | 588 | | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 668 | 668 | | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 748 | 748 | +-----+-----+-----+-----+-----+-----+-----+-----+------+------+

En sak som underlättar om man ska förstå ett exempel är tillgång till hela koden. T.ex. hur ser gd::table ut osv… Speciellt om man som jag inte programmerar i C++.

Det här är en helt annan sak än de tagged-unions du har beskrivit tidigare. Här är all data i din table av samma typ. Du skapar helt enkelt en 8×10 row-major matris.

double dSum = 0.0; uint8_t* puRow = tableSimd.row_get( i ); for( int j = 0; j < 8; ++j ) { dSum += reinterpret_cast<double*>(puRow)[j]; } tableSimd.cell_set( i, 9, dSum );

Om vi tittar på den här delen så gör du alltså en summa reduktion över raderna. Med bara 8 doubles kommer knappast SIMD hjälpa men om du har betydligt fler kolumner så är det fullt möjligt att autovektorisera. Jag ser däremot inget i den där koden som gör det möjligt att autovektorisera. Vad kompilerar du med för flaggor?

Har du tittat på vilken assemblerkod som faktiskt genereras? Jag gissar att du kommer hitta något som liknar det här:

… vxorpd xmm0, xmm0, xmm0 … vaddsd xmm0, xmm0, QWORD PTR […] vaddsd xmm0, xmm0, QWORD PTR 8[…] vaddsd xmm0, xmm0, QWORD PTR 16[…] vaddsd xmm0, xmm0, QWORD PTR 24[…] vaddsd xmm0, xmm0, QWORD PTR 32[…] vaddsd xmm0, xmm0, QWORD PTR 40[…] vaddsd xmm0, xmm0, QWORD PTR 48[…] vaddsd xmm0, xmm0, QWORD PTR 56[…] … vmovsd …, xmm0 call …::cell_set(…) …

Det här känns som ett väldigt krångligt sätt att skriva exemplet "Summera alla värden i en array".

Permalänk
Medlem
Skrivet av jclr:

Det här känns som ett väldigt krångligt sätt att skriva exemplet "Summera alla värden i en array".

Kortis (sent)
Såg du att tabellen vart utskriven med kolumner som beräknat bredden?
Hur tror du tabellen gör det om den inte har koll på varenda värde (angående tagged unions).
En kompilator som är bra kommer unrolla loopen och göra om koden till simd om de flaggorna är satta

Permalänk
Datavetare
Skrivet av klk:

Kortis (sent)
Såg du att tabellen vart utskriven med kolumner som beräknat bredden?
Hur tror du tabellen gör det om den inte har koll på varenda värde (angående tagged unions).
En kompilator som är bra kommer unrolla loopen och göra om koden till simd om de flaggorna är satta

En FYI: autovektorisering kommer nästan aldrig kicka in för flyttal då de inte är associativa, vilket betyder att resultatet kan bli annorlunda från fallet om man följer C++ specifikationen. Så en kompilator är då inte tillåten att göra en sådan optimering, det vore en bug.

Med intrinsics eller std::simd säger man som programmerare: jag säger härmed att det är OK att göra SIMD-optimering här, även om det förändrar resultatet jämfört med en normal skalär form. Autovektorisering är väldigt bräckligt, det kan sluta användas av rätt förvånande orsaker.

Precis som @jclr skriver ovan kompileras den inre loopen i din "SIMD-vänliga kod" till detta

... vxorpd xmm0, xmm0, xmm0 vaddsd xmm0, xmm0, qword ptr [rax] vaddsd xmm0, xmm0, qword ptr [rax + 8] vaddsd xmm0, xmm0, qword ptr [rax + 16] vaddsd xmm0, xmm0, qword ptr [rax + 24] vaddsd xmm0, xmm0, qword ptr [rax + 32] vaddsd xmm0, xmm0, qword ptr [rax + 40] vaddsd xmm0, xmm0, qword ptr [rax + 48] vaddsd xmm0, xmm0, qword ptr [rax + 56] ...

#include <cassert> #include <cstdint> #include <iostream> #include <string> class table; namespace gd { namespace types { struct type_g { type_g(const char *); double as_double(); }; } namespace table { struct tag_io_cli {}; struct tag_prepare {}; std::string to_string(const class table&, tag_io_cli); } } class table { public: table(int, int, int, const gd::types::type_g&, int, gd::table::tag_prepare); int row_add_one(); void cell_set(int, int, double); gd::types::type_g cell_get_variant_view(int, int); uint8_t* row_get(int); std::string to_string(const table&, gd::table::tag_io_cli); }; void foo() { constexpr unsigned uRowCount = 10; // create table with 10 double columns, 8 value columns and two sum columns table tableSimd( uRowCount, 0, 0, gd::types::type_g( "double" ), 10, gd::table::tag_prepare{} ); for( int i = 0; i < uRowCount; ++i ) { // add numbers to each column auto uRow = tableSimd.row_add_one(); for( int j = 0; j < 8; ++j ) { tableSimd.cell_set( uRow, j, (double)(i * 10 + j) ); } } // calculate sum of each row for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; for( int j = 0; j < 8; ++j ) { dSum += tableSimd.cell_get_variant_view(i, j).as_double(); } tableSimd.cell_set( i, 8, dSum ); } // calculate using simd friendly code for( int i = 0; i < uRowCount; ++i ) { double dSum = 0.0; // get pointer to 8 doubles uint8_t* puRow = tableSimd.row_get( i ); bool b8ByteAligned = (reinterpret_cast<uintptr_t>(puRow) % 8) == 0; assert( b8ByteAligned == true ); // sum using simd friendly code for( int j = 0; j < 8; ++j ) { dSum += reinterpret_cast<double*>(puRow)[j]; } double dSum2 = 0.0; double* __restrict pdCells8 = reinterpret_cast<double*>(puRow); for( int j = 0; j < 8; ++j ) { dSum2 += pdCells8[j]; } assert(dSum == dSum2); tableSimd.cell_set( i, 9, dSum ); } // print table to cli { std::string stringTable = gd::table::to_string(tableSimd, gd::table::tag_io_cli{}); std::cout << stringTable << std::endl; } }

denna variant av exempel-koden går att använda i Compiler Explorer

Det går att säga åt kompilatorn att förutsätta att flyttal följer matematikens regler med -ffast-math flaggan.

Är fortfarande inte med på vad din rätt komplicerade tabell skulle tillföra.

#include <array> #include <numeric> #include <iostream> #include <iomanip> #include <cassert> constexpr std::size_t Rows = 10; constexpr std::size_t ValCols = 8; // value columns constexpr std::size_t SumCols = 2; // sum columns constexpr std::size_t TotalCols = ValCols + SumCols; // Use std::array if dimensions are known compile time // use std::vector if they are not using Row = std::array<double, TotalCols>; using Table = std::array<Row, Rows>; void build_and_print() { Table tbl{}; for (auto r = 0; r < Rows; ++r) { auto& row = tbl[r]; for (auto c = 0; c < ValCols; ++c) { row[c] = static_cast<double>(r * 10 + static_cast<int>(c)); } } // Compute sum of first 8 columns into col 8 with a for-loop for (auto r = 0; r < Rows; ++r) { double sum = 0; auto& row = tbl[r]; for (auto c = 0; c < ValCols; ++c) { sum += row[c]; } row[ValCols] = sum; // column 8 } // Using C++ standard numerics into col 9 with std::accumulate() for (auto r = 0; r < Rows; ++r) { auto& row = tbl[r]; double sum = std::accumulate(row.begin(), row.begin() + ValCols, 0.0); assert(sum == row[ValCols]); row[ValCols + 1] = sum; } // Pretty-print as a table auto print_border = []{ std::cout << '+'; for (std::size_t c = 0; c < TotalCols; ++c) std::cout << "------+"; std::cout << '\n'; }; print_border(); for (std::size_t i = 0; i < Rows; ++i) { std::cout << '|'; for (std::size_t j = 0; j < TotalCols; ++j) { std::cout << std::setw(5) << static_cast<int>(tbl[i][j]) << " |"; } std::cout << '\n'; } print_border(); }

Är inte detta rätt mycket lättare att begripa?

std::array<>/std::vector<> är generics fungerar de även med saker som variant vid behov. Och givet trådens topic, från och med C++26 får man också extra säkerhet om man använder standard library hardening finessen.

Visa signatur

Care About Your Craft: Why spend your life developing software unless you care about doing it well? - The Pragmatic Programmer

Permalänk

Utifall det skulle finnas några i tråden som inte kan alla detaljer av X86 instruktions-set utantill, vill jag bara förtydliga att i den genererade koden som @Yoshman postade:

vxorpd xmm0, xmm0, xmm0 vaddsd xmm0, xmm0, qword ptr [rax] vaddsd xmm0, xmm0, qword ptr [rax + 8] vaddsd xmm0, xmm0, qword ptr [rax + 16] vaddsd xmm0, xmm0, qword ptr [rax + 24] vaddsd xmm0, xmm0, qword ptr [rax + 32] vaddsd xmm0, xmm0, qword ptr [rax + 40] vaddsd xmm0, xmm0, qword ptr [rax + 48] vaddsd xmm0, xmm0, qword ptr [rax + 56]

används visserligen SIMD-enheten för beräkningen, men man jobbar bara med ett tal i taget. Det står vaddsd. På engelska:ish skulle det betyda vector, add, scalar, double precision. Den instruktion som heter vaddpd (vector, add, packed, double precision) adderar flera element parallellt. En annan indikation på att man bara jobbar med ett element i taget är att det står qword ptr på minnesoperanden. På X86iska heter det byte, word, dword och qword för 8, 16, 32 och 64 bitar.

Det krävs, som @Yoshman påpekade, att man använder -ffast-math för att denna kod skall vektoriseras. Då kan man få ut följande kod:

vmovupd zmm1, ZMMWORD PTR [rax] ... vextractf64x4 ymm0, zmm1, 0x1 vaddpd ymm0, ymm0, ymm1 vextractf128 xmm1, ymm0, 0x1 vaddpd xmm1, xmm1, xmm0 vunpckhpd xmm0, xmm1, xmm1 vaddpd xmm0, xmm0, xmm1 vzeroupper

Här laddar man ett ZMMWORD (512 bitar = 8 * 64 bitar = 8 doubles) i ett svep. Sedan är det lite pyssel eftersom vi nu vill addera alla 8 elementen i zmm1. Det görs genom att steg för steg ta övre halvan av ett vektorregister och addera till den lägre halvan för att till slut få ut summan av alla åtta elementen i xmm0:s lägsta element.