Чи можете ви збалансувати незбалансований 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;
            }
        }
    }
}

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

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

Зверніть увагу, як усі сплітератори, що повернулися з trySplit, діляться одним pathsітератором. Я подумав, що це дійсно розумний спосіб збалансувати роботу між усіма розбірниками, але цього було недостатньо для досягнення повного паралелізму.

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

Чи існує легкий / простий / канонічний спосіб вирішити цю проблему?


2
Вам потрібна оцінка розміру. Це може бути абсолютно хибним, якщо воно приблизно відображає співвідношення вашого незбалансованого розколу. В іншому випадку потік не знає, що розщеплення є неврівноваженими і зупиняться, коли буде створено певну кількість фрагментів.
Холгер

@Holger чи можете ви детальніше розказати про те, що "зупиниться, коли буде створено певну кількість шматочків" або вкажіть мені на це джерело JDK? Яка кількість шматочків, де вони зупиняються?
Alex R

Код не має значення, оскільки він би показав занадто багато релевантних деталей реалізації, які можуть змінитися в будь-який час. Важливим моментом є те, що реалізація намагається викликати спліт досить часто, так що кожен робочий потік (з урахуванням кількості ядер CPU) має щось робити. Для компенсації непередбачуваних різниць у часі обчислення, ймовірно, вийде навіть більше фрагментів, ніж робочі потоки, щоб дозволити крадіжку роботи та використовувати розрахункові розміри як евристичні (наприклад, вирішити, який підроздільник розщеплювати далі). Дивіться також stackoverflow.com/a/48174508/2711488
Holger

Я зробив кілька експериментів, щоб спробувати зрозуміти ваш коментар. Евристика здається досить примітивною. Схоже, повернення Long.MAX_VALUEвикликає надмірне та непотрібне розщеплення, тоді як будь-яка оцінка, окрім як Long.MAX_VALUEспричиняє подальше розщеплення, зупиняється, вбиваючи паралелізм. Повернення поєднання точних оцінок, схоже, не призводить до інтелектуальних оптимізацій.
Alex R

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

Відповіді:


0

Вам trySplitслід вивести розбиття однакового розміру, незалежно від розміру файлів, що лежать в основі. Ви повинні ставитись до всіх файлів як до єдиного блоку та ArrayListщоразу заповнювати спліторатор, що зберігається, однаковою кількістю об’єктів JSON. Кількість об'єктів повинна бути такою, що обробка одного розбиття займає від 1 до 10 мілісекунд: нижче 1 мс, і ви починаєте наближатися до витрат на передачу партії робочій нитці, вищій за це, і ви починаєте ризикувати нерівномірним завантаженням процесора через завдання, які є занадто грубими.

Сплитератор не зобов’язаний повідомляти про оцінку розміру, і ви вже робите це правильно: ваша оцінка - Long.MAX_VALUEце особливе значення, що означає "без обмежень". Однак якщо у вас є багато файлів з одним об’єктом JSON, в результаті чого виходять партії розміром 1, це зашкодить вашій роботі двома способами: накладні витрати на відкриття-читання-закриття файлу можуть стати вузьким місцем і, якщо вам вдасться вийти що, вартість передачі нитки може бути значною порівняно з витратою на обробку одного елемента, що знову спричиняє вузьке місце.

П’ять років тому я вирішував подібну проблему, ви можете подивитися на моє рішення .


Так, ви "не зобов'язані повідомляти про оцінку розміру" і Long.MAX_VALUEправильно описуєте невідомий розмір, але це не допомагає, коли реальна реалізація потоку не працює тоді. Навіть використання результату 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.