Vraag Waarom zijn elementgewijze toevoegingen veel sneller in afzonderlijke lussen dan in een gecombineerde lus?


Veronderstellen a1, b1, c1, en d1 wijs naar Heap-geheugen en mijn numerieke code heeft de volgende kernlus.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Deze lus wordt 10.000 keer uitgevoerd via een andere buitenzijde for lus. Om het te versnellen, heb ik de code gewijzigd in:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Samengesteld op MS Visual C ++ 10.0 met volledige optimalisatie en SSE2 ingeschakeld voor 32-bits op a Intel Core 2 Duo (x64), het eerste voorbeeld duurt 5,5 seconden en het voorbeeld met dubbele lus duurt slechts 1,9 seconden. Mijn vraag is: (raadpleeg de onderstaande mijn vraag met de nieuwe betekenis)

PS: Ik weet het niet zeker, als dit helpt:

Demonteren voor de eerste lus ziet er in principe zo uit (dit blok wordt ongeveer vijf keer herhaald in het volledige programma):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Elke lus van het voorbeeld met dubbele lus produceert deze code (het volgende blok wordt ongeveer drie keer herhaald):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

De vraag bleek niet relevant, omdat het gedrag sterk afhangt van de grootte van de arrays (n) en de CPU-cache. Dus als er verdere interesse is, herformuleer ik de vraag:

Kun je een goed inzicht geven in de details die leiden tot het verschillende cache-gedrag, zoals geïllustreerd door de vijf regio's in de volgende grafiek?

Het kan ook interessant zijn om de verschillen aan te geven tussen CPU / cache-architecturen, door een vergelijkbare grafiek voor deze CPU's te bieden.

PPS: hier is de volledige code. Het gebruikt TBB  Tick_Count voor timing met een hogere resolutie, die kan worden uitgeschakeld door de. niet te definiëren TBB_TIMING macro:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(Het toont FLOP / s voor verschillende waarden van n.)

enter image description here


1995
2017-12-17 20:40


oorsprong


antwoorden:


Bij nadere analyse hiervan, geloof ik dat dit (op zijn minst gedeeltelijk) wordt veroorzaakt door data-alignment van de vier pointers. Dit veroorzaakt een aantal niveaus van cache-bank / way-conflicten.

Als ik goed heb geraden over hoe u uw arrays toewijst, zij waarschijnlijk worden uitgelijnd op de paginaregel.

Dit betekent dat al je toegangen in elke lus op dezelfde cachemanier vallen. Intel-processors hebben echter al een tijdje associatie met 8-weg L1-cache. Maar in werkelijkheid is de uitvoering niet volledig uniform. Toegang tot 4-manieren is nog steeds langzamer dan zeggen 2-manieren.

EDIT: Het ziet er inderdaad naar uit dat u alle arrays afzonderlijk toewijst. Meestal, wanneer zulke grote toewijzingen worden gevraagd, zal de toewijzer nieuwe pagina's van het besturingssysteem opvragen. Daarom is de kans groot dat grote toewijzingen op dezelfde afstand van een paginarand verschijnen.

Hier is de testcode:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Benchmarkresultaten:

EDIT: resultaten op een werkelijk Core 2 architectuurmachine:

2 x Intel Xeon X5482 Harpertown @ 3.2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

opmerkingen:

  • 6.206 seconden met één lus en 2.116 seconden met twee lussen. Dit reproduceert exact de resultaten van de OP.

  • In de eerste twee tests worden de arrays afzonderlijk toegewezen.U zult merken dat ze allemaal dezelfde uitlijning hebben ten opzichte van de pagina.

  • In de tweede twee tests worden de matrices samengepakt om die uitlijning te verbreken. Hier zie je dat beide lussen sneller zijn. Verder is de tweede (dubbele) lus nu langzamer dan je normaal zou verwachten.

Zoals @Stephen Cannon in de opmerkingen aangeeft, is de kans zeer groot dat deze uitlijning veroorzaakt valse aliasing in de load / store-eenheden of de cache. Ik heb hiernaar Googled en ontdekt dat Intel eigenlijk een hardwareteller voor heeft gedeeltelijke adresaliasing kramen:

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 Regio's - Toelichtingen

Regio 1:

Deze is gemakkelijk. De dataset is zo klein dat de prestaties gedomineerd worden door overhead zoals looping en vertakking.

Regio 2:

Hier neemt de hoeveelheid relatieve overhead af naarmate de datagroottes toenemen en de prestatie "verzadigt". Hier zijn twee lussen langzamer omdat deze twee keer zoveel lus en vertakking over het hoofd heeft.

Ik weet niet precies wat hier aan de hand is ... Afstemming kan nog steeds een effect hebben, zoals Agner Fog het noemt cache bankconflicten. (Die link gaat over Sandy Bridge, maar het idee zou nog steeds van toepassing moeten zijn op Core 2.)

Regio 3:

Op dit punt passen de gegevens niet langer in L1-cache. De prestaties worden dus begrensd door de L1 <-> L2-cachebandbreedte.

Regio 4:

De prestatiedaling in de single-loop is wat we waarnemen. En zoals gezegd, dit komt door de uitlijning die (waarschijnlijk) veroorzaakt valse aliasing in de processor laden / opslaan-eenheden.

Echter, om valse aliasing te laten plaatsvinden, moet er een voldoende grote stap zijn tussen de datasets. Dit is waarom je dit niet ziet in regio 3.

Regio 5:

Op dit moment past niets in de cache. U bent dus gebonden aan geheugenbandbreedte.


2 x Intel X5482 Harpertown @ 3.2 GHz Intel Core i7 870 @ 2.8 GHz Intel Core i7 2600K @ 4.4 GHz


1544
2017-12-17 21:17



OK, het juiste antwoord moet zeker iets doen met de CPU-cache. Maar het gebruik van het cache-argument kan behoorlijk moeilijk zijn, vooral zonder gegevens.

Er zijn veel antwoorden, die tot veel discussie hebben geleid, maar laten we wel wezen: cache-problemen kunnen erg complex zijn en zijn niet eendimensionaal. Ze zijn sterk afhankelijk van de grootte van de gegevens, dus mijn vraag was oneerlijk: het bleek op een heel interessant punt in de cache-grafiek te staan.

Het antwoord van @ Mysticial overtuigde veel mensen (waaronder ikzelf), waarschijnlijk omdat het de enige leek te zijn die op feiten leek te vertrouwen, maar het was slechts één 'gegevenspunt' van de waarheid.

Daarom combineerde ik zijn test (met behulp van een continue versus afzonderlijke toewijzing) en het advies van @James 'Answer.

De onderstaande grafieken laten zien dat de meeste antwoorden en vooral de meerderheid van de opmerkingen op de vraag en de antwoorden als volledig fout of waar kunnen worden beschouwd, afhankelijk van het exacte scenario en de gebruikte parameters.

Merk op dat mijn eerste vraag was bij n = 100.000. Dit punt vertoont (per ongeluk) speciaal gedrag:

  1. Het bezit de grootste discrepantie tussen de één en de twee loop'ed-versie (bijna een factor drie)

  2. Het is het enige punt, waar one-loop (namelijk met continue toewijzing) de versie met twee lussen verslaat. (Dit maakte Mysticial's antwoord mogelijk.)

Het resultaat met behulp van geïnitialiseerde gegevens:

Enter image description here

Het resultaat met behulp van niet-geïnitialiseerde gegevens (dit is wat Mysticial heeft getest):

Enter image description here

En dit is een moeilijk te verklaren: geïnitialiseerde gegevens, die eenmaal worden toegewezen en hergebruikt voor elk volgend testgeval met verschillende vectorafmetingen:

Enter image description here

Voorstel

Elke low-level performance-gerelateerde vraag over Stack Overflow moet worden vereist om MFLOPS-informatie te bieden voor het hele scala van cache-relevante gegevensformaten! Het is verspilling van ieders tijd om te denken aan antwoorden en deze met anderen te bespreken zonder deze informatie.


194
2017-12-18 01:29



De tweede lus bevat veel minder cacheactiviteit, dus het is gemakkelijker voor de processor om aan de geheugenvereisten te voldoen.


63
2017-12-17 20:47



Stel je voor dat je aan een machine werkt waar n was precies de juiste waarde om slechts twee van uw arrays tegelijkertijd in het geheugen te kunnen opslaan, maar het totale beschikbare geheugen, via schijfcaching, was nog steeds voldoende om alle vier te bewaren.

Uitgaande van een eenvoudig LIFO cachebeleid, deze code:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

zou eerst veroorzaken a en b om in RAM te worden geladen en dan volledig in RAM wordt gewerkt. Wanneer de tweede lus begint, c en d zou dan van schijf naar RAM worden geladen en geopereerd worden.

de andere lus

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

zal twee rijen opzoeken en pagina in de andere twee elke keer rond de lus. Dit zou natuurlijk zo zijn veel langzamer.

U ziet waarschijnlijk geen schijfcache in uw tests, maar u ziet waarschijnlijk de bijwerkingen van een andere vorm van caching.


Er lijkt hier een beetje verwarring / misverstand te zijn, dus ik zal proberen een klein beetje uit te werken aan de hand van een voorbeeld.

Zeggen n = 2 en we werken met bytes. In mijn scenario hebben we dat dus slechts 4 bytes aan cache en de rest van ons geheugen is aanzienlijk langzamer (zeg 100 maal langere toegang).

Uitgaande van een vrij dom cachebeleid van als de byte zich niet in de cache bevindt, plaats hem dan en neem ook de volgende byte, terwijl we bezig zijn je krijgt een scenario als volgt:

  • Met

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • cache a[0] en a[1] dan b[0] en b[1] En instellen a[0] = a[0] + b[0] in de cache - er zijn nu vier bytes in de cache, a[0], a[1] en b[0], b[1]. Kosten = 100 + 100.

  • reeks a[1] = a[1] + b[1] in de cache. Kosten = 1 + 1.
  • Herhaal voor c en d.
  • Totale kosten = (100 + 100 + 1 + 1) * 2 = 404

  • Met

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • cache a[0] en a[1] dan b[0] en b[1] En instellen a[0] = a[0] + b[0] in de cache - er zijn nu vier bytes in de cache, a[0], a[1] en b[0], b[1]. Kosten = 100 + 100.

  • uitwerpen a[0], a[1], b[0], b[1] van cache en cache c[0] en c[1] dan d[0] en d[1] En instellen c[0] = c[0] + d[0] in de cache. Kosten = 100 + 100.
  • Ik vermoed dat je begint te zien waar ik heen ga.
  • Totale kosten = (100 + 100 + 100 + 100) * 2 = 800

Dit is een klassiek cache-thrash-scenario.


37
2017-12-18 01:36



Het is niet vanwege een andere code, maar vanwege caching: RAM is langzamer dan dat de CPU registreert en er bevindt zich een cachegeheugen in de CPU om te voorkomen dat het RAM-geheugen wordt geschreven elke keer dat een variabele verandert. Maar de cache is niet zo groot als het RAM-geheugen, daarom wijst het slechts een fractie ervan toe.

De eerste code modificeert verre geheugenadressen afwisselend bij elke lus, waardoor het continu nodig is om de cache ongeldig te maken.

De tweede code wisselt niet: hij vloeit slechts tweemaal over naastgelegen adressen. Dit zorgt ervoor dat alle taken worden voltooid in de cache, waardoor deze pas wordt vrijgegeven nadat de tweede lus is gestart.


27
2017-12-17 20:49



Ik kan de hier besproken resultaten niet repliceren.

Ik weet niet of een slechte benchmark-code de schuld is, of wat, maar de twee methoden bevinden zich binnen 10% van elkaar op mijn machine met behulp van de volgende code, en een lus is meestal net iets sneller dan twee - zoals je zou doen verwachten.

Arraymaten varieerden van 2 ^ 16 tot 2 ^ 24, met behulp van acht lussen. Ik was voorzichtig met het initialiseren van de source-arrays, dus de += opdracht vroeg niet aan de FPU om geheugenspullen toe te voegen, geïnterpreteerd als een dubbel.

Ik speelde met verschillende schema's, zoals de toewijzing van b[j], d[j] naar InitToZero[j] in de lussen, en ook met gebruik += b[j] = 1 en += d[j] = 1en ik kreeg redelijk consistente resultaten.

Zoals je zou verwachten, initialiseren b en d in de lus gebruiken InitToZero[j] gaf de gecombineerde aanpak een voordeel, omdat ze back-to-back werden uitgevoerd vóór de toewijzingen aan a en c, maar nog steeds binnen 10%. Ga figuur.

Hardware is Dell XPS 8500 met generatie 3 Core i7 @ 3.4 GHz en 8 GB geheugen. Voor 2 ^ 16 tot 2 ^ 24, met acht lussen, was de cumulatieve tijd respectievelijk 44.987 en 40.965. Visual C ++ 2010, volledig geoptimaliseerd.

PS: ik heb de loops gewijzigd in aftellen naar nul en de gecombineerde methode was iets sneller. Kras mijn hoofd. Let op de nieuwe array-omvang en het aantal lussen.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

Ik weet niet zeker waarom is besloten dat MFLOPS een relevante statistiek is. Ik dacht dat het de bedoeling was om me te concentreren op geheugentoegang, dus probeerde ik de hoeveelheid drijvende-komma-rekentijd te minimaliseren. Ik ging weg in de +=, maar ik weet niet zeker waarom.

Een rechte toewijzing zonder berekening zou een schonere test zijn van de toegangstijd tot geheugen en zou een test creëren die uniform is, ongeacht het aantal lussen. Misschien heb ik iets gemist in het gesprek, maar het is de moeite waard om er twee keer over na te denken. Als de plus buiten de toewijzing wordt gehouden, is de cumulatieve tijd bijna identiek op elk 31 seconden.


16
2017-12-30 01:34



Dit komt omdat de CPU niet zoveel cachemissers heeft (waar moet worden gewacht tot de arraygegevens afkomstig zijn van de RAM-chips). Het zou interessant zijn om de grootte van de arrays continu aan te passen, zodat u de afmetingen van de array overschrijdt niveau 1 cache (L1), en vervolgens de level 2 cache (L2) van uw CPU en een grafiek van de tijd die het kost om uw code uit te voeren met de grootte van de arrays. De grafiek mag geen rechte lijn zijn zoals u zou verwachten.


14
2017-12-17 20:52



De eerste lus wisselt het schrijven in elke variabele af. De tweede en derde maken slechts kleine sprongen van elementgrootte.

Probeer twee parallelle lijnen van 20 kruisen te schrijven met een pen en papier van elkaar gescheiden door 20 cm. Probeer een keer een en vervolgens de andere regel af te maken en probeer een andere keer door afwisselend een kruis in elke regel te schrijven.


12
2017-08-17 15:23