Concorrenza concomitante C ++

4

Sto scrivendo un ray tracer e desidero aggiungere concorrenza per ovvi (anche se probabilmente piccoli) guadagni in termini di prestazioni.

Nel mio codice, faccio scorrere tutti i punti centrali nella griglia di pixel che forma la mia immagine finale per tracciarli. La mia idea di fare questo in parallelo è scrivere un qualche tipo di monitor che ritorni atomicamente al prossimo punto della griglia che deve ancora essere tracciato. Per questo sto usando questo:

class PixelGetter{
public:

PixelGetter(Point beginning, Vect xIncrement, Vect yIncrement, unsigned int width, unsigned int height)
        : mCurrentPixel(beginning), mxIncrement(xIncrement), myIncrement(yIncrement),
          mWidth(width), mHeight(height), mX(0), mY(0), mLock(){}

/**
 * Atomically returns a new point in the imaginary pixel grid.
 * @return New point in the imaginary pixel grid or nullptr if the last
 * valid point was returned before.
 */
unique_ptr<tuple<Point, unsigned int, unsigned int>> GetNextPixel() {
    lock_guard<mutex> lock{mLock};
    if(mY >= mHeight) return nullptr;

    auto retVal = make_unique<tuple<Point, unsigned int, unsigned int>>(mCurrentPixel, mX, mY);
    if(mX == mWidth)
    {
        mX = 0; mY++;
        mCurrentPixel += myIncrement;
        mCurrentPixel -= mxIncrement * mWidth;
    }
    else
    {
        mX++;
        mCurrentPixel += mxIncrement;
    }
    return retVal;
}

private:
    Point mCurrentPixel;
    Vect mxIncrement, myIncrement;
    unsigned int mWidth, mHeight, mX, mY;
    mutable mutex mLock;
};

Penso che ci dovrebbe essere un modo più semplice per farlo che non riesco a vedere.

Le mie idee iniziali riguardavano l'uso dell'atomica in qualche modo, ma ho bisogno di restituire un punto e passare al successivo atomicamente e non posso pensare a un modo per farlo.

Quale sarebbe un buon modo per farlo? Se potrebbe essere applicato generalmente meglio.

    
posta MJ Galram 09.11.2016 - 20:38
fonte

1 risposta

5

Quello che stai realmente facendo è dividere l'immagine in pezzi e trattare ogni pezzo come un'attività da completare.

Vedo due cose principali che cambierei con la tua attuale implementazione.

Il primo è che stai suddividendo il problema in parti così piccole che probabilmente stai imponendo un po 'di overhead nel blocco, nella comunicazione e così via. Invece di un singolo punto, prenderei in considerazione l'invio di un certo numero di punti contigui in una volta (ad esempio, possibilmente un'intera linea di scansione, anche se non lo inserirò nel codice), in modo da poter ammortizzare il blocco e il sovraccarico della comunicazione su un'attività più grande.

Il secondo è che probabilmente puoi semplificare la maggior parte del codice un po 'mettendo una coda concorrente tra il produttore (che genera le attività) e il consumatore (elaborando le linee di scansione). In questo caso hai una coda single-producer, multi-consumer, che è un pattern ben noto che è relativamente facile da fare in varie forme (lock, variabili di condizione, lock-free usando atomics e così via).

In questo modo il tuo produttore diventa qualcosa del tipo:

void produce(std::vector<pixel> const &pic, size_t task_size, concurrent::queue<task> &q) { 

    for (int i=0; i<pic.size(); i+= task_size)
        q.push(task(&pic[i], task_size));
}

... e il tuo consumatore assomiglia a qualcosa del tipo:

void consume(concurrent::queue<task> &q) { 
    task t;
    for (int i=0; i<t.size(); i++)
        ray_trace(t.data+i);
}

Ora, è vero che se si guarda il codice all'interno della coda concorrente, è probabile che si veda una buona quantità di bruttezza in qualche modo simile al codice che si ha sopra. La grande differenza qui è che una coda SPMC è una cosa comune, ampiamente utilizzata e ben nota che si qualifica quasi come una primitiva nella programmazione simultanea, quindi è abbastanza facile trovare le librerie che le hanno già, quindi non devi pasticcio con l'implementazione.

Anche se ne realizzi uno tu stesso, è comunque uno strumento generico che puoi quasi certamente riutilizzare un po '(con un po' di attenzione nel progettare l'interfaccia).

Un altro punto è che stai limitando il codice complicato che ha a che fare con simultaneità e blocco (o atomics, programmazione lock-free, ecc.) in un pacchetto ordinato, autonomo che è ragionevolmente facile da testare, ecc. È anche possibile iniziare con un progetto basato su lock piuttosto semplice, e successivamente aggiornarlo a un progetto probabilmente più difficile senza blocco, con pochi o nessun disturbo ad altro codice.

Per limitare il "danno" dalle attività che si rivelano per richiedere un tempo di calcolo disparato, in genere si desidera iniziare stimando il tempo di calcolo del caso peggiore. Quindi si considera una situazione in cui tutti i thread terminano l'esecuzione di altre attività contemporaneamente e nella coda è rimasta solo una delle attività peggiori. Ovviamente può essere eseguito solo da un thread, quindi ciò che si vuole fare è limitare il tempo extra necessario per eseguire in modo seriale quell'attività.

Ad esempio, supponiamo che stai elaborando un'immagine di 600 x 400 pixel. Una prima scelta ovvia sarebbe dividerla in compiti di una linea di scansione a testa. Questo ti dà 400 compiti. Supponendo che tu stia utilizzando un processore quad-core, hai una media di 100 attività per core.

Il caso peggiore che può succedere è che tutti e 4 i core finiscono le loro attività contemporaneamente, e c'è solo un compito rimasto in coda, quindi per la durata di tale attività hai solo un core di esecuzione invece di 4.

Per motivi di discussione, supponiamo che la variazione tra l'attività più veloce possibile e l'attività più lenta possibile sia 16: 1 e che la distribuzione sia simmetrica (ovvero, il compito più veloce impiega un quarto per quanto tempo media, e il più lento quattro volte la media).

Quindi, se il nostro nucleo centrale stava eseguendo un'attività media, quell'attività occuperebbe circa l'1% del tempo complessivo di esecuzione. Se fosse stato suddiviso in quattro parti perfettamente uguali, avrebbe assunto solo lo 0,25 percento (ignorando qualsiasi overhead aggiuntivo che potesse imporre), quindi la nostra penalità per l'utilizzo di un compito di grandi dimensioni è di circa lo 0,75 percento. Dal momento che il caso peggiore è 4 volte quello, la pena peggiore è di circa il 3%.

Ci sono due modi ovvi per ridurre l'impatto di questo caso peggiore. Il primo è semplicemente dividere il lavoro in pezzi più piccoli. Ad un certo punto, tuttavia, questo aggiunge un sovraccarico sufficiente (blocco extra, comunicazione aggiuntiva) che è una strategia perdente. Il secondo è limitare il rapporto tra i casi migliori e quelli peggiori. Questo tende ad essere più difficile, perché nella maggior parte dei casi non è immediatamente ovvio se un compito sarà alla fine veloce, alla fine lenta o in un punto intermedio. Tuttavia, spesso è possibile ridurre la probabilità che si verifichi un caso veramente terribile. Ad esempio, è possibile utilizzare qualcosa come un registro di spostamento a retroazione lineare per selezionare i pixel, quindi ogni attività viene selezionata in modo relativamente casuale, quindi se (per esempio) c'è una sfera riflettente nella scena che si traduce in un sacco di pixel che vengono tracciati attraverso molte riflessioni, in genere vengono suddivise tra un sacco di compiti anziché concentrati in pochi.

    
risposta data 09.11.2016 - 21:08
fonte

Leggi altre domande sui tag