Bisakah Anda menyeimbangkan kembali Spliterator yang tidak seimbang dengan ukuran yang tidak diketahui?

12

Saya ingin menggunakan Streamuntuk memparalelkan pemrosesan set heterogen file JSON yang disimpan dari nomor yang tidak dikenal (jumlah file tidak diketahui dimuka). Ukuran file dapat sangat bervariasi, mulai dari 1 catatan JSON per file hingga 100.000 catatan di beberapa file lainnya. Sebuah JSON catatan dalam hal ini berarti mandiri JSON objek direpresentasikan sebagai satu baris dalam file.

Saya benar-benar ingin menggunakan Streaming untuk ini dan jadi saya menerapkan ini 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;
            }
        }
    }
}

Masalah yang saya alami adalah ketika Stream memparalelkan dengan indah pada awalnya, akhirnya file terbesar dibiarkan diproses dalam satu utas. Saya percaya penyebab proksimal didokumentasikan dengan baik: spliterator "tidak seimbang".

Lebih konkret, tampak bahwa trySplitmetode ini tidak dipanggil setelah titik tertentu dalam Stream.forEachsiklus hidup, sehingga logika ekstra untuk mendistribusikan batch kecil pada akhir trySplitjarang dieksekusi.

Perhatikan bagaimana semua spliterator yang dikembalikan dari trySplit berbagi pathsiterator yang sama . Saya pikir ini adalah cara yang sangat pintar untuk menyeimbangkan pekerjaan di semua pembagi, tapi itu belum cukup untuk mencapai paralelisme penuh.

Saya ingin pemrosesan paralel untuk melanjutkan pertama di file, dan kemudian ketika beberapa file besar masih dibiarkan membelah, saya ingin memparalelkan antar potongan file yang tersisa. Itulah maksud dari elseblok di akhir trySplit.

Apakah ada cara yang mudah / sederhana / kanonik untuk mengatasi masalah ini?

Alex R
sumber
2
Anda membutuhkan perkiraan ukuran. Ini bisa benar-benar palsu, asalkan itu secara kasar mencerminkan rasio perpecahan tidak seimbang Anda. Kalau tidak, aliran tidak tahu bahwa perpecahan tidak seimbang dan akan berhenti setelah sejumlah chunks telah dibuat.
Holger
@ Holger dapat Anda jelaskan "akan berhenti setelah sejumlah potongan telah dibuat" atau tunjukkan saya di sumber JDK untuk ini? Berapa jumlah bongkahan di mana ia berhenti?
Alex R
Kode ini tidak relevan, karena akan menunjukkan terlalu banyak detail implementasi yang tidak relevan, yang dapat berubah kapan saja. Poin yang relevan adalah, bahwa implementasi mencoba memanggil split cukup sering, sehingga setiap thread pekerja (disesuaikan dengan jumlah core CPU) memiliki sesuatu untuk dilakukan. Untuk mengkompensasi perbedaan tak terduga dalam waktu komputasi, kemungkinan akan menghasilkan potongan lebih banyak daripada benang pekerja untuk memungkinkan mencuri kerja dan menggunakan ukuran yang diperkirakan sebagai heuristik (misalnya untuk memutuskan sub spliterator mana yang akan dipecah lebih lanjut). Lihat juga stackoverflow.com/a/48174508/2711488
Holger
Saya melakukan beberapa percobaan untuk mencoba memahami komentar Anda. Heuristik tampaknya sangat primitif. Sepertinya, kembali Long.MAX_VALUEmenyebabkan pembelahan yang berlebihan dan tidak perlu, sementara setiap perkiraan selain Long.MAX_VALUEpenyebab pembelahan selanjutnya berhenti, membunuh paralelisme. Mengembalikan campuran perkiraan yang akurat tampaknya tidak mengarah ke optimasi cerdas.
Alex R
Saya tidak mengklaim bahwa strategi implementasi sangat cerdas, tetapi setidaknya, ia bekerja untuk beberapa skenario dengan perkiraan ukuran (jika tidak, ada lebih banyak laporan bug tentang itu). Jadi sepertinya, ada beberapa kesalahan di pihak Anda selama percobaan. Misalnya, dalam kode pertanyaan Anda, Anda memperluas AbstractSpliteratortetapi mengesampingkan trySplit()yang merupakan kombo yang buruk untuk apa pun selain Long.MAX_VALUE, karena Anda tidak mengadaptasi perkiraan ukuran di trySplit(). Setelah itu trySplit(), estimasi ukuran harus dikurangi dengan jumlah elemen yang telah dipisahkan.
Holger

Jawaban:

0

trySplitKeluaran Anda harus berukuran sama, terlepas dari ukuran file yang mendasarinya. Anda harus memperlakukan semua file sebagai satu unit dan mengisi ArrayListspliterator yang dikembalikan dengan jumlah objek JSON yang sama setiap kali. Jumlah objek harus sedemikian rupa sehingga pemrosesan satu split membutuhkan waktu antara 1 dan 10 milidetik: lebih rendah dari 1 ms dan Anda mulai mendekati biaya menyerahkan batch ke thread pekerja, lebih tinggi dari itu dan Anda mulai berisiko beban CPU yang tidak merata karena tugas-tugas yang terlalu kasar.

Pemisah tidak wajib melaporkan perkiraan ukuran, dan Anda sudah melakukan ini dengan benar: perkiraan Anda adalah Long.MAX_VALUE, yang merupakan nilai khusus yang berarti "tidak terikat". Namun, jika Anda memiliki banyak file dengan objek JSON tunggal, menghasilkan kumpulan ukuran 1, ini akan merusak kinerja Anda dalam dua cara: overhead pembukaan-membaca-menutup file mungkin menjadi hambatan dan, jika Anda berhasil melarikan diri bahwa, biaya handoff benang mungkin signifikan dibandingkan dengan biaya pemrosesan satu item, sekali lagi menyebabkan kemacetan.

Lima tahun yang lalu saya memecahkan masalah yang sama, Anda dapat melihat solusi saya .

Marko Topolnik
sumber
Ya, Anda "tidak wajib melaporkan perkiraan ukuran" dan Long.MAX_VALUEdengan benar menggambarkan ukuran yang tidak diketahui, tetapi itu tidak membantu ketika implementasi Stream yang sebenarnya berkinerja buruk saat itu. Bahkan menggunakan hasil ThreadLocalRandom.current().nextInt(100, 100_000)estimasi ukuran menghasilkan hasil yang lebih baik.
Holger
Ini berkinerja baik untuk kasus penggunaan saya, di mana biaya komputasi setiap item sangat besar. Saya dengan mudah mencapai 98% penggunaan CPU total dan throughput diskalakan hampir linear dengan paralelisme. Pada dasarnya, penting untuk mendapatkan ukuran bets yang tepat sehingga prosesnya membutuhkan antara 1 dan 10 milidetik. Itu jauh di atas biaya thread handoff dan tidak terlalu lama untuk menyebabkan masalah tugas granularity. Saya telah menerbitkan hasil benchmark menjelang akhir posting ini .
Marko Topolnik
Solusi Anda terpecah ArraySpliteratoryang memiliki ukuran yang diperkirakan (bahkan ukuran yang tepat). Jadi implementasi Stream akan melihat ukuran array vs Long.MAX_VALUE, pertimbangkan ini tidak seimbang dan pisahkan spliterator "lebih besar" (mengabaikan itu Long.MAX_VALUEberarti "tidak diketahui"), sampai tidak dapat membelah lebih lanjut. Kemudian, jika tidak ada cukup potongan, ia akan membagi spliterator berbasis array menggunakan ukuran yang diketahui. Ya, ini bekerja dengan sangat baik, tetapi tidak bertentangan dengan pernyataan saya bahwa Anda memerlukan perkiraan ukuran, terlepas dari seberapa buruknya.
Holger
OK, jadi sepertinya kesalahpahaman --- karena Anda tidak perlu estimasi ukuran pada input. Hanya pada pemisahan individu, dan Anda selalu dapat memilikinya.
Marko Topolnik
Nah, komentar pertama saya adalah " Anda perlu perkiraan ukuran. Ini bisa benar-benar palsu, asalkan itu secara kasar mencerminkan rasio dari perpecahan tidak seimbang Anda. " masih melaporkan ukuran yang tidak diketahui. Inilah yang membuat implementasi Stream tidak berdaya. Setiap angka perkiraan untuk spliterator baru yang secara signifikan lebih kecil Long.MAX_VALUEakan dilakukan.
Holger
0

Setelah banyak percobaan, saya masih tidak bisa mendapatkan paralelisme tambahan dengan bermain dengan perkiraan ukuran. Pada dasarnya, nilai apa pun selain Long.MAX_VALUEakan cenderung menyebabkan spliterator berakhir terlalu dini (dan tanpa pemisahan apa pun), sementara di sisi lain Long.MAX_VALUEperkiraan akan menyebabkan trySplitdipanggil tanpa henti hingga kembali null.

Solusi yang saya temukan adalah berbagi sumber daya secara internal di antara para pembagi dan membiarkan mereka menyeimbangkan kembali di antara mereka sendiri.

Kode kerja:

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;
            }
        }
    }
}
Alex R
sumber