您可以重新平衡大小未知的不平衡分离器吗?


12

我想使用a 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;
            }
        }
    }
}

我遇到的问题是,虽然流一开始漂亮地并行化,但最终最大的文件仍留在单个线程中处理。我认为,近端原因已得到充分证明:分离器“不平衡”。

更具体地讲,似乎trySplitStream.forEach生命周期中的某个点之后没有调用该方法,因此在末尾分发小批量的额外逻辑trySplit很少执行。

请注意,从trySplit返回的所有拆分paths器如何共享同一个迭代器。我认为这是在所有拆分器之间平衡工作的非常聪明的方法,但是还不足以实现完全并行。

我希望并行处理首先在文件之间进行,然后在仍然拆分几个大文件时,我希望在剩余文件的大块之间进行并行处理。这就是else区块末尾的意图trySplit

解决这个问题有简单/简单/规范的方法吗?


2
您需要尺寸估算。只要它大致反映了您不平衡拆分的比例,它就可能是完全虚假的。否则,流将不知道拆分不平衡,一旦创建了一定数量的块,该流将停止。
Holger

@Holger您可以详细说明“一旦创建一定数量的块将停止”,或者为此将我指向JDK源代码?停止的块数是多少?
Alex R

该代码无关紧要,因为它将显示太多不相关的实现细节,并且可能随时更改。相关的一点是,该实现会尝试足够频繁地调用split,以使每个工作线程(根据CPU内核的数量进行调整)都有所要做。为了补偿计算时间的不可预测的差异,它可能会产生比工作线程更多的块,以允许进行工作窃取并将估计的大小用作试探法(例如,确定要进一步拆分的子拆分器)。另请参阅stackoverflow.com/a/48174508/2711488
Holger

我做了一些实验,试图了解您的评论。启发式方法似乎很原始。看起来,返回Long.MAX_VALUE会导致过多和不必要的拆分,而任何估计Long.MAX_VALUE都会导致进一步的拆分停止,从而杀死并行性。返回准确的估计值混合似乎不会导致任何智能的优化。
亚历克斯R

我并不是在说实现的策略非常聪明,但是至少它可以在某些具有估计大小的场景下工作(否则,有关此问题的错误报告会更多)。如此看来,实验过程中您的身边有些错误。例如,在您问题的代码中,您正在扩展AbstractSpliterator但被覆盖trySplit(),这对于除之外的其他任何事情都是不好的组合Long.MAX_VALUE,因为您没有适应中的大小估计trySplit()。之后trySplit(),大小估算值应减少已拆分元素的数量。
Holger

Answers:


0

trySplit应该同等大小的输出分裂,不管底层文件的大小。您应该将所有文件视为一个单元,并且ArrayList每次使用相同数量的JSON对象填充-backed分隔符。对象的数量应使得处理一次拆分的时间在1到10毫秒之间:小于1毫秒,并且您开始接近将批处理移交给工作线程的成本,高于此成本,并且由于以下原因而开始冒着CPU负载不均匀的风险太粗粒度的任务。

分隔符没有义务报告大小估计,并且您已经正确执行了此操作:您的估计值为Long.MAX_VALUE,这是一个特殊值,表示“无界”。但是,如果您有多个带有单个JSON对象的文件,导致批处理的大小为1,则这将在两种方面损害您的性能:打开,读取,关闭文件的开销可能会成为瓶颈,并且,如果设法逃脱与处理一件商品的成本相比,线程移交的成本可能会很大,这又会造成瓶颈。

五年前,我正在解决一个类似的问题,您可以看看我的解决方案


是的,您“没有义务报告大小估算值”,并且Long.MAX_VALUE可以正确描述未知的大小,但是当实际Stream实现的效果不佳时,这无济于事。即使使用ThreadLocalRandom.current().nextInt(100, 100_000)估计大小的结果也会产生更好的结果。
Holger

对于我的用例来说,它的性能很好,因为每个用例的计算成本都很高。我很容易达到98%的总CPU使用率,并且吞吐量几乎与并行度成线性比例。基本上,正确设置批处理大小非常重要,这样处理时间将在1到10毫秒之间。这远高于任何线程切换成本,并且不会太长时间而导致任务粒度问题。我已在本博文末发布了基准测试结果。
Marko Topolnik

您的溶液分裂出一个ArraySpliterator,其具有的估计大小(甚至一个确切的大小)。因此,Stream实现将看到数组大小vs Long.MAX_VALUE,考虑到这种不平衡,并拆分“较大”的拆分器(忽略这Long.MAX_VALUE意味着“未知”),直到无法进一步拆分为止。然后,如果没有足够的块,它将利用其已知大小拆分基于数组的拆分器。是的,这非常有效,但是与我的说法无关,无论大小有多高,您都需要估算。
霍尔格

好的,所以这似乎是一种误解-因为您不需要输入的大小估算。只是在单个拆分上,您始终可以拥有它。
Marko Topolnik '19

好吧,我的第一句话是“ 您需要大小估计。它可以完全是伪造的,只要它能大致反映出不平衡拆分的比率即可。 ”关键是OP的代码创建了另一个包含单个元素的拆分器,但是仍报告未知大小。这就是使Stream实现无奈的原因。新分离器的任何估计数量都将大大减少Long.MAX_VALUE
Holger

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;
            }
        }
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.