Riesci a riequilibrare uno Spliterator sbilanciato di dimensioni sconosciute?


12

Voglio utilizzare a Streamper parallelizzare l'elaborazione di un insieme eterogeneo di file JSON memorizzati in remoto di numero sconosciuto (il numero di file non è noto in anticipo). Le dimensioni dei file possono variare notevolmente, da 1 record JSON per file fino a 100.000 record in alcuni altri file. Un record JSON in questo caso significa un oggetto JSON autonomo rappresentato come una riga nel file.

Voglio davvero usare Streams per questo e quindi ho implementato questo Spliterator:

public abstract class JsonStreamSpliterator<METADATA, RECORD> extends AbstractSpliterator<RECORD> {

    abstract protected JsonStreamSupport<METADATA> openInputStream(String path);

    abstract protected RECORD parse(METADATA metadata, Map<String, Object> json);

    private static final int ADDITIONAL_CHARACTERISTICS = Spliterator.IMMUTABLE | Spliterator.DISTINCT | Spliterator.NONNULL;
    private static final int MAX_BUFFER = 100;
    private final Iterator<String> paths;
    private JsonStreamSupport<METADATA> reader = null;

    public JsonStreamSpliterator(Iterator<String> paths) {
        this(Long.MAX_VALUE, ADDITIONAL_CHARACTERISTICS, paths);
    }

    private JsonStreamSpliterator(long est, int additionalCharacteristics, Iterator<String> paths) {
        super(est, additionalCharacteristics);
        this.paths = paths;
    }

    private JsonStreamSpliterator(long est, int additionalCharacteristics, Iterator<String> paths, String nextPath) {
        this(est, additionalCharacteristics, paths);
        open(nextPath);
    }

    @Override
    public boolean tryAdvance(Consumer<? super RECORD> action) {
        if(reader == null) {
            String path = takeNextPath();
            if(path != null) {
                open(path);
            }
            else {
                return false;
            }
        }
        Map<String, Object> json = reader.readJsonLine();
        if(json != null) {
            RECORD item = parse(reader.getMetadata(), json);
            action.accept(item);
            return true;
        }
        else {
            reader.close();
            reader = null;
            return tryAdvance(action);
        }
    }

    private void open(String path) {
        reader = openInputStream(path);
    }

    private String takeNextPath() {
        synchronized(paths) {
            if(paths.hasNext()) {
                return paths.next();
            }
        }
        return null;
    }

    @Override
    public Spliterator<RECORD> trySplit() {
        String nextPath = takeNextPath();
        if(nextPath != null) {
            return new JsonStreamSpliterator<METADATA,RECORD>(Long.MAX_VALUE, ADDITIONAL_CHARACTERISTICS, paths, nextPath) {
                @Override
                protected JsonStreamSupport<METADATA> openInputStream(String path) {
                    return JsonStreamSpliterator.this.openInputStream(path);
                }
                @Override
                protected RECORD parse(METADATA metaData, Map<String,Object> json) {
                    return JsonStreamSpliterator.this.parse(metaData, json);
                }
            };              
        }
        else {
            List<RECORD> records = new ArrayList<RECORD>();
            while(tryAdvance(records::add) && records.size() < MAX_BUFFER) {
                // loop
            }
            if(records.size() != 0) {
                return records.spliterator();
            }
            else {
                return null;
            }
        }
    }
}

Il problema che sto riscontrando è che mentre lo Stream si parallelizza magnificamente all'inizio, alla fine il file più grande viene lasciato elaborato in un singolo thread. Credo che la causa prossimale sia ben documentata: il divisore è "sbilanciato".

Più concretamente, sembra che il trySplitmetodo non venga chiamato dopo un certo punto del Stream.forEachciclo di vita, quindi trySplitraramente viene eseguita la logica aggiuntiva per distribuire piccoli lotti alla fine .

Notare come tutti i divisori restituiti da trySplit condividano lo stesso pathsiteratore. Ho pensato che fosse un modo davvero intelligente per bilanciare il lavoro tra tutti i divisori, ma non è stato abbastanza per raggiungere il pieno parallelismo.

Vorrei che l'elaborazione parallela procedesse dapprima tra i file e poi, quando pochi file di grandi dimensioni rimangono ancora divisi, voglio parallelizzare tra i pezzi dei file rimanenti. Questo era l'intento del elseblocco alla fine di trySplit.

Esiste un modo semplice / semplice / canonico per aggirare questo problema?


2
Hai bisogno di un preventivo. Può essere totalmente fasullo, purché rifletta approssimativamente il rapporto tra la tua divisione sbilanciata. Altrimenti, il flusso non sa che le divisioni sono sbilanciate e si interromperà una volta creato un certo numero di blocchi.
Holger,

@Holger puoi approfondire "si fermerà una volta creato un certo numero di blocchi" o mi indicherà la fonte JDK per questo? Qual è il numero di blocchi in cui si ferma?
Alex R

Il codice è irrilevante, in quanto mostrerebbe troppi dettagli di implementazione irrilevanti, che potrebbero cambiare in qualsiasi momento. Il punto rilevante è che l'implementazione cerca di chiamare split abbastanza spesso, in modo che ogni thread di lavoro (adattato al numero di core della CPU) abbia qualcosa da fare. Per compensare differenze imprevedibili nel tempo di calcolo, probabilmente produrrà ancora più blocchi dei thread di lavoro per consentire il furto di lavoro e utilizzare le dimensioni stimate come euristiche (ad esempio per decidere quale sub-splitterator dividere ulteriormente). Vedi anche stackoverflow.com/a/48174508/2711488
Holger

Ho fatto alcuni esperimenti per cercare di capire il tuo commento. L'euristica sembra essere piuttosto primitiva. Sembra che il ritorno Long.MAX_VALUEprovochi una divisione eccessiva e non necessaria, mentre qualsiasi stima oltre a Long.MAX_VALUEcausare un'ulteriore divisione per arrestare, uccidendo il parallelismo. Restituire un mix di stime accurate non sembra portare a ottimizzazioni intelligenti.
Alex R

Non sto affermando che la strategia di implementazione sia stata molto intelligente, ma almeno, funziona per alcuni scenari con dimensioni stimate (altrimenti, ci sono state molte più segnalazioni di bug al riguardo). Quindi sembra che ci siano stati degli errori dalla tua parte durante gli esperimenti. Ad esempio, nel codice della tua domanda, stai estendendo AbstractSpliteratorma ignorando il trySplit()che è una cattiva combinazione per qualsiasi cosa diversa da Long.MAX_VALUE, poiché non stai adattando la stima delle dimensioni in trySplit(). Successivamente trySplit(), la stima delle dimensioni dovrebbe essere ridotta del numero di elementi che sono stati suddivisi.
Holger,

Risposte:


0

Il tuo trySplitdovrebbe divide uscita di uguale dimensione, indipendentemente dalla dimensione dei file sottostanti. Dovresti trattare tutti i file come una singola unità e riempire ArrayListogni volta il divisore con il retro con lo stesso numero di oggetti JSON. Il numero di oggetti dovrebbe essere tale che l'elaborazione di una divisione richieda tra 1 e 10 millisecondi: inferiore a 1 ms e si inizia ad avvicinarsi ai costi di consegna del batch a un thread di lavoro, superiore a quello e si inizia a rischiare un carico irregolare della CPU a causa di compiti troppo grossolani.

Il divisore non è obbligato a segnalare una stima delle dimensioni e lo stai già facendo correttamente: la tua stima è Long.MAX_VALUE, che è un valore speciale che significa "illimitato". Tuttavia, se si dispone di molti file con un singolo oggetto JSON, con conseguenti lotti di dimensioni 1, ciò comprometterà le prestazioni in due modi: il sovraccarico di apertura-lettura-chiusura del file può diventare un collo di bottiglia e, se si riesce a scappare che, il costo del trasferimento del thread può essere significativo rispetto al costo di elaborazione di un articolo, causando nuovamente un collo di bottiglia.

Cinque anni fa stavo risolvendo un problema simile, puoi dare un'occhiata alla mia soluzione .


Sì, non sei "obbligato a segnalare una stima delle dimensioni" e stai Long.MAX_VALUEdescrivendo correttamente una dimensione sconosciuta, ma ciò non aiuta quando l'implementazione effettiva dello Stream funziona male. Anche usando il risultato di ThreadLocalRandom.current().nextInt(100, 100_000)dimensioni stimate si ottengono risultati migliori.
Holger,

Ha funzionato bene per i miei casi d'uso, in cui il costo computazionale di ogni articolo era notevole. Stavo raggiungendo facilmente il 98% di utilizzo totale della CPU e il throughput scalato quasi linearmente con il parallelismo. Fondamentalmente, è importante ottenere la dimensione corretta del batch in modo che l'elaborazione richieda tra 1 e 10 millisecondi. Questo è ben al di sopra dei costi di trasferimento dei thread e non troppo tempo per causare problemi di granularità delle attività. Ho pubblicato risultati di riferimento verso la fine di questo post .
Marko Topolnik,

La tua soluzione si divide da una ArraySpliteratorche ha una dimensione stimata (anche una dimensione esatta). Quindi l'implementazione dello Stream vedrà la dimensione dell'array rispetto a Long.MAX_VALUE, considera questo sbilanciato e divide lo splitterator "più grande" (ignorando che Long.MAX_VALUEsignifica "sconosciuto"), fino a quando non può dividere ulteriormente. Quindi, se non ci sono abbastanza pezzi, dividerà i divisori basati su array usando le loro dimensioni conosciute. Sì, funziona molto bene, ma non contraddice la mia affermazione che è necessario un preventivo di dimensioni, indipendentemente da quanto sia scadente.
Holger,

OK, quindi sembra un malinteso --- perché non hai bisogno di una stima delle dimensioni sull'input. Solo sulle singole divisioni, e puoi sempre averlo.
Marko Topolnik,

Bene, il mio primo commento è stato " Hai bisogno di una stima delle dimensioni. Può essere totalmente falso, purché rifletta approssimativamente il rapporto della tua divisione sbilanciata. " Il punto chiave qui è stato che il codice del PO crea un altro spliterator contenente un singolo elemento ma riporta ancora una dimensione sconosciuta. Questo è ciò che rende impotente l'implementazione di Stream. Qualsiasi numero stimato per il nuovo splitterator sarebbe significativamente più piccolo di quanto Long.MAX_VALUEfarebbe.
Holger,

0

Dopo molte sperimentazioni, non ero ancora in grado di ottenere alcun parallelismo aggiunto giocando con le stime delle dimensioni. Fondamentalmente, qualsiasi valore diverso da quello Long.MAX_VALUEtenderà a far terminare il divisore troppo presto (e senza alcuna divisione), mentre d'altra parte una Long.MAX_VALUEstima farà sì trySplitche venga chiamata incessantemente fino a quando non ritorna null.

La soluzione che ho trovato è quella di condividere internamente le risorse tra i divisori e lasciarli riequilibrare tra loro.

Codice di lavoro:

public class AwsS3LineSpliterator<LINE> extends AbstractSpliterator<AwsS3LineInput<LINE>> {

    public final static class AwsS3LineInput<LINE> {
        final public S3ObjectSummary s3ObjectSummary;
        final public LINE lineItem;
        public AwsS3LineInput(S3ObjectSummary s3ObjectSummary, LINE lineItem) {
            this.s3ObjectSummary = s3ObjectSummary;
            this.lineItem = lineItem;
        }
    }

    private final class InputStreamHandler {
        final S3ObjectSummary file;
        final InputStream inputStream;
        InputStreamHandler(S3ObjectSummary file, InputStream is) {
            this.file = file;
            this.inputStream = is;
        }
    }

    private final Iterator<S3ObjectSummary> incomingFiles;

    private final Function<S3ObjectSummary, InputStream> fileOpener;

    private final Function<InputStream, LINE> lineReader;

    private final Deque<S3ObjectSummary> unopenedFiles;

    private final Deque<InputStreamHandler> openedFiles;

    private final Deque<AwsS3LineInput<LINE>> sharedBuffer;

    private final int maxBuffer;

    private AwsS3LineSpliterator(Iterator<S3ObjectSummary> incomingFiles, Function<S3ObjectSummary, InputStream> fileOpener,
            Function<InputStream, LINE> lineReader,
            Deque<S3ObjectSummary> unopenedFiles, Deque<InputStreamHandler> openedFiles, Deque<AwsS3LineInput<LINE>> sharedBuffer,
            int maxBuffer) {
        super(Long.MAX_VALUE, 0);
        this.incomingFiles = incomingFiles;
        this.fileOpener = fileOpener;
        this.lineReader = lineReader;
        this.unopenedFiles = unopenedFiles;
        this.openedFiles = openedFiles;
        this.sharedBuffer = sharedBuffer;
        this.maxBuffer = maxBuffer;
    }

    public AwsS3LineSpliterator(Iterator<S3ObjectSummary> incomingFiles, Function<S3ObjectSummary, InputStream> fileOpener, Function<InputStream, LINE> lineReader, int maxBuffer) {
        this(incomingFiles, fileOpener, lineReader, new ConcurrentLinkedDeque<>(), new ConcurrentLinkedDeque<>(), new ArrayDeque<>(maxBuffer), maxBuffer);
    }

    @Override
    public boolean tryAdvance(Consumer<? super AwsS3LineInput<LINE>> action) {
        AwsS3LineInput<LINE> lineInput;
        synchronized(sharedBuffer) {
            lineInput=sharedBuffer.poll();
        }
        if(lineInput != null) {
            action.accept(lineInput);
            return true;
        }
        InputStreamHandler handle = openedFiles.poll();
        if(handle == null) {
            S3ObjectSummary unopenedFile = unopenedFiles.poll();
            if(unopenedFile == null) {
                return false;
            }
            handle = new InputStreamHandler(unopenedFile, fileOpener.apply(unopenedFile));
        }
        for(int i=0; i < maxBuffer; ++i) {
            LINE line = lineReader.apply(handle.inputStream);
            if(line != null) {
                synchronized(sharedBuffer) {
                    sharedBuffer.add(new AwsS3LineInput<LINE>(handle.file, line));
                }
            }
            else {
                return tryAdvance(action);
            }
        }
        openedFiles.addFirst(handle);
        return tryAdvance(action);
    }

    @Override
    public Spliterator<AwsS3LineInput<LINE>> trySplit() {
        synchronized(incomingFiles) {
            if (incomingFiles.hasNext()) {
                unopenedFiles.add(incomingFiles.next());
                return new AwsS3LineSpliterator<LINE>(incomingFiles, fileOpener, lineReader, unopenedFiles, openedFiles, sharedBuffer, maxBuffer);
            } else {
                return null;
            }
        }
    }
}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.