【问题标题】:Fast parsing of strings of numbers in java在java中快速解析数字字符串
【发布时间】:2015-10-11 11:09:13
【问题描述】:

我发现了很多关于如何将包含双精度数字的 ASCII 文件解析为 Java 中的双精度数组的不同建议。我目前使用的大致如下:

stream = FileInputStream(fname);
breader = BufferedReader(InputStreamReader(stream));
scanner = java.util.Scanner(breader);    
array = new double[size]; // size is known upfront
idx = 0;
try {
        while(idx<size){
           array[idx] = scanner.nextDouble();
           idx++;
        }
}
catch {...}

对于具有 100 万个数字的示例文件,此代码大约需要 2 秒。用 C 编写的类似代码,使用fscanf,需要 0.1 秒(!)显然我错了。由于开销,我想多次调用nextDouble() 是错误的方法,但我想不出更好的方法。

我不是 Java 专家,因此我需要一些帮助:你能告诉我如何改进这段代码吗?

编辑对应的C代码如下

  fd = fopen(fname, "r+");
  vals = calloc(sizeof(double), size);
  do{
    nel = fscanf(fd, "%lf", vals+idx);
    idx++;
  } while(nel!=-1);

【问题讨论】:

  • 你能告诉我们“用 C 编写的类似代码”吗?
  • 您是否分析了代码以检查哪些部分是性能瓶颈?
  • @DraganBozanovic 不,我没有。我对Java没有太多经验,我用emacs来编程。这是一小段代码,我猜瓶颈应该很明显。它确实存在于while循环中:)我不怀疑它来自array[idx]和内存分配。
  • “就是这么一小段代码” 不,不是。您调用的库中有很多代码。不过,我建议您使用探查器尝试一下。另外,请记住,Java 具有 JIT,在开始优化代码时可能会消耗一些时间。例如,您能否尝试执行相同的 sn-p 代码 100 次并检查最后几次迭代的执行时间?
  • 10 次迭代需要 10 倍以上的时间。时间花在循环中。

标签: java arrays performance parsing


【解决方案1】:

(总结一下我在c​​mets中已经提到的一些东西:)

您应该小心手动基准测试。问题How do I write a correct micro-benchmark in Java? 的答案指出了一些基本注意事项。但是,这种情况不太容易出现经典陷阱。事实上,情况可能恰恰相反:当基准测试包含读取文件时,那么您很可能不是对代码进行基准测试,而主要是对硬盘进行基准测试。这涉及到缓存的常见副作用。

但是,显然超出纯文件 IO 的开销。

您应该知道Scanner 类非常强大和方便。但在内部,它是一个由大型正则表达式组成的野兽,向用户隐藏了巨大的复杂性——当您的意图只是读取 double 值时,这种复杂性根本没有必要!

有一些开销较小的解决方案。

不幸的是,最简单的解决方案仅适用于输入中的数字由行分隔符分隔的情况。然后,将这个文件读入一个数组可以写成

double result[] = 
    Files.lines(Paths.get(fileName))
        .mapToDouble(Double::parseDouble)
        .toArray();

这甚至可能相当快。当一行中有 多个 数字时(正如您在评论中提到的那样),则可以扩展:

double result[] = 
    Files.lines(Paths.get(fileName))
        .flatMap(s -> Stream.of(s.split("\\s+")))
        .mapToDouble(Double::parseDouble)
        .toArray();

所以关于如何有效地从文件中读取一组double 值的一般问题,用空格分隔(但不一定用换行符分隔),我写了一个小测试。

这不应该被视为一个真正的基准,并且可以持保留态度,但它至少尝试解决一些基本问题:它读取不同大小的文件,多次,使用不同的方法,以便后面的运行,所有方法的硬盘缓存效果应该是一样的:

更新以生成注释中描述的示例数据,并添加了基于流的方法

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StreamTokenizer;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Random;
import java.util.Scanner;
import java.util.StringTokenizer;
import java.util.stream.Stream;

public class ReadingFileWithDoubles
{
    private static final int MIN_SIZE = 256000;
    private static final int MAX_SIZE = 2048000;

    public static void main(String[] args) throws IOException
    {
        generateFiles();

        long before = 0;
        long after = 0;
        double result[] = null;

        for (int n=MIN_SIZE; n<=MAX_SIZE; n*=2)
        {
            String fileName = "doubles"+n+".txt";

            for (int i=0; i<10; i++)
            {
                before = System.nanoTime();
                result = readWithScanner(fileName, n);
                after = System.nanoTime();

                System.out.println(
                    "size = " + n + 
                    ", readWithScanner                  " + 
                        (after - before) / 1e6 + 
                    ", result " + result);

                before = System.nanoTime();
                result = readWithStreamTokenizer(fileName, n);
                after = System.nanoTime();

                System.out.println(
                    "size = " + n + 
                    ", readWithStreamTokenizer          " + 
                        (after - before) / 1e6 +
                    ", result " + result);

                before = System.nanoTime();
                result = readWithBufferAndStringTokenizer(fileName, n);
                after = System.nanoTime();

                System.out.println(
                    "size = " + n + 
                    ", readWithBufferAndStringTokenizer " + 
                        (after - before) / 1e6 + 
                    ", result " + result);

                before = System.nanoTime();
                result = readWithStream(fileName, n);
                after = System.nanoTime();

                System.out.println(
                    "size = " + n + 
                    ", readWithStream                   " + 
                        (after - before) / 1e6 + 
                    ", result " + result);
            }
        }

    }



    private static double[] readWithScanner(
        String fileName, int size) throws IOException
    {
        try (
            InputStream is = new FileInputStream(fileName);
            InputStreamReader isr = new InputStreamReader(is);
            BufferedReader br = new BufferedReader(isr);
            Scanner scanner = new Scanner(br))
        {
            // Do this to avoid surprises on systems with a different locale!
            scanner.useLocale(Locale.ENGLISH);

            int idx = 0;
            double array[] = new double[size];
            while (idx < size)
            {
                array[idx] = scanner.nextDouble();
                idx++;
            }
            return array;
        }
    }

    private static double[] readWithStreamTokenizer(
        String fileName, int size) throws IOException
    {
        try (
            InputStream is = new FileInputStream(fileName);
            InputStreamReader isr = new InputStreamReader(is);
            BufferedReader br = new BufferedReader(isr))
        {
            StreamTokenizer st = new StreamTokenizer(br);            
            st.resetSyntax();
            st.wordChars('0', '9');
            st.wordChars('.', '.');
            st.wordChars('-', '-');
            st.wordChars('e', 'e');
            st.wordChars('E', 'E');
            double array[] = new double[size];
            int index = 0;
            boolean eof = false;
            do
            {
                int token = st.nextToken();
                switch (token)
                {
                    case StreamTokenizer.TT_EOF:
                        eof = true;
                        break;

                    case StreamTokenizer.TT_WORD:
                        double d = Double.parseDouble(st.sval);
                        array[index++] = d;
                        break;
                }
            } while (!eof);
            return array;
        }
    }

    // This one is reading the whole file into memory, as a String,
    // which may not be appropriate for large files
    private static double[] readWithBufferAndStringTokenizer(
        String fileName, int size) throws IOException
    {
        double array[] = new double[size];
        try (
            InputStream is = new FileInputStream(fileName);
            InputStreamReader isr = new InputStreamReader(is);
            BufferedReader br = new BufferedReader(isr))
        {
            StringBuilder sb = new StringBuilder();
            char buffer[] = new char[1024];
            while (true)
            {
                int n = br.read(buffer);
                if (n == -1)
                {
                    break;
                }
                sb.append(buffer, 0, n);
            }
            int index = 0;
            StringTokenizer st = new StringTokenizer(sb.toString());
            while (st.hasMoreTokens())
            {
                array[index++] = Double.parseDouble(st.nextToken());
            }
            return array;
        }
    }

    private static double[] readWithStream(
        String fileName, int size) throws IOException
    {
        double result[] = 
            Files.lines(Paths.get(fileName))
                .flatMap(s -> Stream.of(s.split("\\s+")))
                .mapToDouble(Double::parseDouble)
                .toArray();
        return result;
    }


    private static void generateFiles() throws IOException 
    {
        for (int n=MIN_SIZE; n<=MAX_SIZE; n*=2)
        {
            String fileName = "doubles"+n+".txt";
            if (!new File(fileName).exists())
            {
                System.out.println("Creating "+fileName);
                writeDoubles(new FileOutputStream(fileName), n);
            }
            else
            {
                System.out.println("File "+fileName+" already exists");
            }
        }
    }
    private static void writeDoubles(OutputStream os, int n) throws IOException
    {
        OutputStreamWriter writer = new OutputStreamWriter(os);
        Random random = new Random(0);
        int numbersPerLine = random.nextInt(4) + 1;
        for (int i=0; i<n; i++)
        {
            writer.write(String.valueOf(random.nextDouble()));
            numbersPerLine--;
            if (numbersPerLine == 0)
            {
                writer.write("\n");
                numbersPerLine = random.nextInt(4) + 1;
            }
            else
            {
                writer.write(" ");
            }
        }
        writer.close();
    }
}

它比较了 4 种方法:

  • 使用Scanner 阅读,如原始代码 sn-p 中所示
  • 阅读StreamTokenizer
  • 将整个文件读入String,并用StringTokenizer对其进行剖析
  • 将文件读取为Stream 行,然后平面映射到Stream 标记,然后映射到DoubleStream

将文件作为一个大的String 读取可能并不适用于所有情况:当文件变得(非常)大时,将整个文件作为String 保存在内存中可能不是一个可行的解决方案。

测试运行(在相当旧的 PC 上,使用慢速硬盘驱动器(无固态))大致显示了以下结果:

...
size = 1024000, readWithScanner                  9932.940919, result [D@1c7353a
size = 1024000, readWithStreamTokenizer          1187.051427, result [D@1a9515
size = 1024000, readWithBufferAndStringTokenizer 1172.235019, result [D@f49f1c
size = 1024000, readWithStream                   2197.785473, result [D@1469ea2    ...

很明显,扫描器会产生相当大的开销,当更直接地从流中读取时,可以避免这种开销。

这可能不是最终答案,因为可能会有更高效和/或更优雅的解决方案(我期待看到它们!),但至少它可能会有所帮助。


编辑

一个小评论:一般来说,这些方法之间存在一定的概念差异。粗略地说,区别在于谁来决定读取的元素数量。在伪代码中,这种区别是

double array[] = new double[size];
for (int i=0; i<size; i++) 
{
    array[i] = readDoubleFromInput();
}

double array[] = new double[size];
int index = 0;
while (thereAreStillNumbersInTheInput())
{
    double d = readDoubleFromInput();
    array[index++] = d;
}

您最初使用扫描仪的方法与第一种方法类似,而我提出的解决方案与第二种方法更相似。但这在这里应该没有太大的区别,假设 size 确实是 real 大小,并且潜在的错误(如输入中的数字太少或太多)不会出现或不会出现以其他方式处理。

【讨论】:

  • 非常感谢你,Marco13 :) 这看起来真的很不错!我将使用它,看看它如何处理我的数据,但最后一个解决方案似乎给出了“C”结果。我在想,你能不修改它,以便在有限大小的缓冲区上使用 StringTokenizer,然后将其放入循环中吗?通过这种方式,您将在更大的缓冲区上工作,并且仍然使用 StringTokenizer。无论如何,我确定我做错了,再次感谢您的帮助:-)
  • @angainor 当然有混合解决方案,比如在“块”(而不是大的单个缓冲区)中读取文件,然后处理这些块。更详细的分析可能是值得的,但它们可能会变得更加复杂 - 例如必须确保这些块不会分割数字,如@9​​87654341@(切割)45 0.456 - 当然可以处理,但可能有点繁琐。顺便说一句:我会在几分钟内添加一个小的(更一般的)评论作为 EDIT...
  • @Marco13:只是好奇。我们可以直接将流标记为双精度数字,而不是往返将字符串解析为双精度数字吗?
  • @dragon66 从理论上讲,一个人可以读取每个单独的字符,并像在FloatingDecimal.readJavaFormatString 中使用String 的字符一样处理它。但是,逐个字符地读取流通常是一个坏主意:通常应该进行一些缓冲(即,至少应该将一定数量的字符读入char[]),这样会非常无论如何接近String。很难预测任何性能优势,但我的直觉是努力与性能增益的比率会相当糟糕......
  • @Marco13:我的意思是在您的代码中,您使用 case StreamTokenizer.TT_WORD 来获取下一个令牌作为字符串。为什么不直接使用 StreamTokenizer.TT_NUMBER?
猜你喜欢
  • 2023-02-22
  • 1970-01-01
  • 2020-03-26
  • 2020-02-28
  • 1970-01-01
  • 2011-03-11
  • 1970-01-01
  • 1970-01-01
  • 2010-09-24
相关资源
最近更新 更多