Можете ли вы восстановить баланс несбалансированного Spliterator неизвестного размера?


12

Я хочу использовать Streamдля распараллеливания обработки разнородного набора JSON-файлов с удаленным хранением неизвестного числа (количество файлов не известно заранее). Размер файлов может варьироваться от 1 записи JSON на файл до 100 000 записей в некоторых других файлах. Запись JSON в этом случае означает автономный объект JSON, представленный одной строкой в ​​файле.

Я действительно хочу использовать Streams для этого, и поэтому я реализовал это 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;
            }
        }
    }
}

Проблема, с которой я столкнулся, заключается в том, что, хотя Stream сначала прекрасно распараллеливается, в конечном итоге самый большой файл остается обработанным в одном потоке. Я полагаю, что проксимальная причина хорошо задокументирована: сплитератор "несбалансирован".

Более конкретно, кажется, что trySplitметод не вызывается после определенной точки Stream.forEachжизненного цикла, поэтому дополнительная логика для распределения небольших пакетов в конце trySplitредко выполняется.

Обратите внимание, что все сплитераторы, возвращаемые из trySplit, используют один и тот же pathsитератор. Я думал, что это действительно умный способ сбалансировать работу всех сплитераторов, но этого было недостаточно для достижения полного параллелизма.

Я хотел бы, чтобы параллельная обработка сначала выполнялась для файлов, а затем, когда несколько больших файлов все еще остаются сплитерирующими, я хочу распараллелить фрагменты оставшихся файлов. Это было намерение elseблока в конце trySplit.

Есть ли простой / простой / канонический способ обойти эту проблему?


2
Вам нужна оценка размера. Это может быть полностью фальшивым, если оно примерно отражает соотношение вашего несбалансированного разделения. В противном случае поток не будет знать, что разбиения несбалансированы, и остановится, как только будет создано определенное количество фрагментов.
Хольгер

@ Хольгер, можешь ли ты уточнить, что «остановится, когда будет создано определенное количество кусков», или укажи мне на это источник в JDK? Какое количество кусков, где он останавливается?
Алекс Р

Код не имеет значения, так как он будет показывать слишком много нерелевантных деталей реализации, которые могут измениться в любое время. Важным моментом является то, что реализация пытается вызывать split достаточно часто, чтобы у каждого рабочего потока (с учетом количества ядер ЦП) было что-то делать. Чтобы компенсировать непредсказуемые различия во времени вычислений, он, вероятно, произведет даже больше фрагментов, чем рабочие потоки, чтобы обеспечить кражу работы и использовать предполагаемые размеры как эвристические (например, чтобы решить, какой субсплитератор разделить дальше). См. Также stackoverflow.com/a/48174508/2711488
Хольгер

Я провел несколько экспериментов, чтобы понять ваш комментарий. Эвристика кажется довольно примитивной. Похоже, что возврат Long.MAX_VALUEвызывает чрезмерное и ненужное расщепление, в то время как любая другая оценка, кроме того, Long.MAX_VALUEчто останавливает дальнейшее расщепление, убивает параллелизм. Возвращение набора точных оценок, по-видимому, не приводит к какой-либо разумной оптимизации.
Алекс Р

Я не утверждаю, что стратегия реализации была очень умной, но, по крайней мере, она работает для некоторых сценариев с предполагаемыми размерами (в противном случае об этом было гораздо больше сообщений об ошибках). Похоже, что во время экспериментов на вашей стороне были некоторые ошибки. Например, в коде вашего вопроса вы расширяете, AbstractSpliteratorно переопределяете, trySplit()что является плохой комбинацией для чего-либо кроме Long.MAX_VALUE, поскольку вы не адаптируете оценку размера в trySplit(). После trySplit()этого оценка размера должна быть уменьшена на количество разделенных элементов.
Хольгер

Ответы:


0

Вы trySplitдолжны вывести сплиты одинакового размера, независимо от размера базовых файлов. Вы должны рассматривать все файлы как один блок и ArrayListкаждый раз заполнять сплитератор с обратной связью одинаковым количеством объектов JSON. Число объектов должно быть таким, чтобы обработка одного разбиения занимала от 1 до 10 миллисекунд: менее 1 мс, и вы начинаете приближаться к затратам на передачу пакета в рабочий поток, выше этого, и вы начинаете рисковать неравномерной загрузкой ЦП из-за задачи, которые слишком грубы.

Сплитератор не обязан сообщать оценку размера, и вы уже делаете это правильно: ваша оценка Long.MAX_VALUE- это специальное значение, означающее «неограниченный». Однако, если у вас много файлов с одним объектом JSON, что приводит к пакетам размером 1, это ухудшит вашу производительность двумя способами: издержки открытия-чтения-закрытия файла могут стать узким местом и, если вам удастся сбежать что стоимость передачи потока может быть значительной по сравнению со стоимостью обработки одного элемента, что снова вызывает узкое место.

Пять лет назад я решал похожую проблему, вы можете взглянуть на мое решение .


Да, вы «не обязаны сообщать об оценке размера» и Long.MAX_VALUEправильно описывает неизвестный размер, но это не помогает, когда фактическая реализация Stream работает плохо. Даже использование результата в ThreadLocalRandom.current().nextInt(100, 100_000)качестве оценочного размера дает лучшие результаты.
Хольгер

Это хорошо работало для моих случаев использования, где вычислительные затраты каждого элемента были значительными. Я легко достигал 98% общего использования ЦП и пропускной способности, почти линейно масштабируемой с параллелизмом. По сути, важно правильно определить размер пакета, чтобы обработка заняла от 1 до 10 миллисекунд. Это намного выше затрат на передачу обслуживания любого потока и не слишком долго, чтобы вызвать проблемы с детализацией задачи. К концу этого поста я опубликовал результаты тестов .
Марко Топольник

Ваше решение отделяет тот, ArraySpliteratorкоторый имеет приблизительный размер (даже точный размер). Таким образом, реализация Stream увидит размер массива vs Long.MAX_VALUE, посчитает его несбалансированным и разделит «больший» сплитератор (игнорируя это Long.MAX_VALUEозначает «неизвестный»), пока он не сможет разделиться дальше. Затем, если фрагментов недостаточно, он разделит разделители на основе массива, используя их известные размеры. Да, это работает очень хорошо, но не противоречит моему утверждению, что вам нужна оценка размера, независимо от того, насколько он беден.
Хольгер

Итак, похоже, это недоразумение - потому что вам не нужна оценка размера на входе. Просто на отдельных расколах, и вы всегда можете иметь это.
Марко Топольник

Ну, мой первый комментарий был « Вам нужна оценка размера. Она может быть полностью фиктивной, если она примерно отражает соотношение вашего несбалансированного разделения ». Ключевым моментом здесь было то, что код OP создает другой сплитератор, содержащий один элемент, но все еще сообщает неизвестный размер. Это то, что делает реализацию Stream беспомощной. Любое оценочное число для нового сплитератора будет значительно меньше, чем Long.MAX_VALUEбудет.
Хольгер

0

После долгих экспериментов я все еще не смог получить никакого дополнительного параллелизма, играя с оценками размера. По сути, любое значение, отличное от, Long.MAX_VALUEможет привести к преждевременному завершению работы сплитератора (и без какого-либо разделения), тогда как, с другой стороны, Long.MAX_VALUEоценка будет вызываться trySplitбезостановочно, пока не вернется null.

Решение, которое я нашел, состоит в том, чтобы внутренне распределить ресурсы между сплитераторами и позволить им восстановить баланс между собой.

Рабочий код:

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;
            }
        }
    }
}
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.