假设您有一个大的ASCII文本文件,每行都有一个随机的非负整数,每个整数的范围是0到1,000,000,000。文件中有100,000,000行。什么是读取文件并计算所有整数之和的最快方法?
约束:我们有10MB的RAM可供使用。该文件大小为1GB,因此我们不想阅读整个文件然后进行处理。
以下是我尝试过的各种解决方案。我发现结果相当令人惊讶。
我错过了什么更快的事情?
请注意:以下所有时间用于运行算法 10次(运行一次并丢弃;启动计时器;运行10次;停止计时器)。这台机器是一款相当慢的Core 2 Duo。
首先要尝试的是明显的方法:
private long sumLineByLine() throws NumberFormatException, IOException {
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
long total = 0;
while ((line = br.readLine()) != null) {
int k = Integer.parseInt(line);
total += k;
}
br.close();
return total;
}
请注意,最大可能的返回值是10 ^ 17,它仍然很容易适合long
,因此我们不必担心溢出。
在我的机器上,运行此次11次并对第一次运行进行折扣大约需要 92.9秒。
受到对this question的评论的启发,我尝试不创建新的int k
来存储解析该行的结果,而只是将解析后的值直接添加到total
。所以这个:
while ((line = br.readLine()) != null) {
int k = Integer.parseInt(line);
total += k;
}
成为这个:
while ((line = br.readLine()) != null)
total += Integer.parseInt(line);
我确信这不会有任何区别,并且认为编译器很可能会为这两个版本生成相同的字节码。但是,令我惊讶的是,它确实刮掉了一点时间:我们下降到 92.1秒。
到目前为止,令我困扰的一点是,我们将String
转换为int
,然后在最后添加它。我们去的时候可能不会加快速度吗?如果我们自己解析String
会发生什么?像这样......
private long sumLineByLineManualParse() throws NumberFormatException,
IOException {
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
long total = 0;
while ((line = br.readLine()) != null) {
char chs[] = line.toCharArray();
int mul = 1;
for (int i = chs.length - 1; i >= 0; i--) {
char c = chs[i];
switch (c) {
case '0':
break;
case '1':
total += mul;
break;
case '2':
total += (mul << 1);
break;
case '4':
total += (mul << 2);
break;
case '8':
total += (mul << 3);
break;
default:
total += (mul*((byte) c - (byte) ('0')));
}
mul*=10;
}
}
br.close();
return total;
}
我想,这可能会节省一点时间,特别是对于进行乘法的一些比特优化。但转换为字符数组的开销必须淹没任何收益:现在需要 148.2秒。
我们可以尝试的最后一件事是将文件作为二进制数据处理。
如果你不知道它的长度,从正面解析一个整数是很尴尬的。向后解析它要容易得多:您遇到的第一个数字是单位,下一个是数字,依此类推。因此,最简单的方法就是向后读取文件。
如果我们分配一个(例如)8MB的byte[]
缓冲区,我们可以用文件的最后8MB填充它,处理它,然后读取前面的8MB,依此类推。我们需要小心谨慎,当我们移动到下一个区块时,我们不会搞砸我们在解析过程中的数字,但这是唯一的问题。
当我们遇到一个数字时,我们将它(根据其在数字中的位置适当地乘以)加到总数中,然后将系数乘以10,以便我们为下一个数字做好准备。如果我们遇到任何不是数字(CR或LF)的东西,我们只需重置系数。
private long sumBinary() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[8*1024*1024];
int mul = 1;
long total = 0;
while (lastRead>0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead-len);
raf.readFully(buf, 0, len);
lastRead-=len;
for (int i=len-1; i>=0; i--) {
//48 is '0' and 57 is '9'
if ((buf[i]>=48) && (buf[i]<=57)) {
total+=mul*(buf[i]-48);
mul*=10;
} else
mul=1;
}
}
raf.close();
return total;
}
这在 30.8秒中运行!与之前的最佳速度相比,速度增加了3倍
。String
的开销吗?关于字符集等的幕后所有令人担忧的事情?MappedByteBuffer
帮助我们能做得更好吗?我有一种感觉,调用从缓冲区读取方法的开销会降低速度,特别是从缓冲区向后读取时。首先,观察。它本来应该发生在我之前,但我认为基于String
的阅读效率低下的原因并不是创建所有String
对象所花费的时间,而是因为它们是如此昙花一现:我们已经为垃圾收集者处理了100,000,000个垃圾收集器。这肯定会让它感到不安。
现在根据人们发布的答案/评论进行了一些实验。
一个建议是,由于BufferedReader
使用的是16KB的默认缓冲区,并且我使用了8MB的缓冲区,因此我没有比较喜欢。如果你使用更大的缓冲区,它必然会更快。
这是震惊。 sumBinary()
方法(方法4)昨天在30.8秒内以8MB缓冲区运行。今天,代码不变,风向已经改变,我们的时间为30.4秒。如果我将缓冲区大小降低到16KB以查看它变慢了多少,它变得更快!它现在以 23.7秒运行。疯。谁看到那个人来了?!
一些实验表明16KB是最佳的。也许Java家伙做了同样的实验,这就是他们为什么选择16KB的原因!
我也想知道这件事。在磁盘访问上花了多少时间,在数字运算上花了多少钱?如果几乎所有的磁盘访问权限,正如其中一个提议的答案的良好支持评论所暗示的那样,那么无论我们做什么,我们都无法做出很大的改进。
这很容易通过运行代码来进行测试,所有解析和数字运算都被注释掉了,但读数仍然完整:
private long sumBinary() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int mul = 1;
long total = 0;
while (lastRead > 0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead - len);
raf.readFully(buf, 0, len);
lastRead -= len;
/*for (int i = len - 1; i >= 0; i--) {
if ((buf[i] >= 48) && (buf[i] <= 57)) {
total += mul * (buf[i] - 48);
mul *= 10;
} else
mul = 1;
}*/
}
raf.close();
return total;
}
现在运行 3.7秒!这看起来并不是I / O绑定的。
当然,一些I / O速度将来自磁盘缓存命中。但这不是真正的重点:我们仍然需要20秒的CPU时间(也使用Linux&#39; time
命令确认),这足够大,可以尝试减少它
我在原始帖子中保留了有充分理由向后扫描文件而不是向前扫描文件。我没有解释得那么好。我们的想法是,如果您向前扫描一个数字,则必须累计扫描数字的总值,然后将其添加。如果向后扫描,则可以将其添加到累计总计中。我的潜意识对自己有了某种意义(后面会更多),但是我错过了一个关键点,在其中一个答案中指出:向后扫描,我每次迭代进行两次乘法运算,但是向前扫描你只需要一个。所以我编写了一个前向扫描版本:
private long sumBinaryForward() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int fileLength = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int acc = 0;
long total = 0;
int read = 0;
while (read < fileLength) {
int len = Math.min(buf.length, fileLength - read);
raf.readFully(buf, 0, len);
read += len;
for (int i = 0; i < len; i++) {
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
total += acc;
acc = 0;
}
}
}
raf.close();
return total;
}
以 20.0秒运行,远远超过后向扫描版本。好的。
虽然我在夜间意识到的是,虽然我每次迭代执行两次乘法,但是有可能使用缓存来存储这些乘法,这样我就可以避免在向后迭代期间执行它们。我很高兴看到当我醒来时有人有同样的想法!
关键是我们正在扫描的数字中最多有10位数字,而且只有10位可能的数字,因此数字值的累计总数只有100种。我们可以预先计算这些,然后在后向扫描代码中使用它们。这应该击败前向扫描版本,因为我们现在完全摆脱了乘法。 (注意,我们不能通过正向扫描来实现这一点,因为乘法是累加器,它可以取任何值,最多可达10 ^ 9.只有在两个操作数限制为的后向情况下才是这样。一些可能性。)
private long sumBinaryCached() throws IOException {
int mulCache[][] = new int[10][10];
int coeff = 1;
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; j++)
mulCache[i][j] = coeff * j;
coeff *= 10;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int mul = 0;
long total = 0;
while (lastRead > 0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead - len);
raf.readFully(buf, 0, len);
lastRead -= len;
for (int i = len - 1; i >= 0; i--) {
if ((buf[i] >= 48) && (buf[i] <= 57))
total += mulCache[mul++][buf[i] - 48];
else
mul = 0;
}
}
raf.close();
return total;
}
这在 26.1秒中运行。令人失望,至少可以这么说。向后阅读在I / O方面效率较低,但我们已经看到I / O不是这里的主要问题。我原以为这会产生很大的积极影响。也许阵列查找和我们替换的乘法一样昂贵。 (我确实尝试使用16x16阵列,并使用位移索引,但它没有帮助。)
看起来正向扫描就在它的位置。
要添加的下一件事是MappedByteBuffer
,看看它是否比使用原始RandomAccessFile
更有效。它不需要对代码进行太多更改。
private long sumBinaryForwardMap() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
byte buf[] = new byte[16 * 1024];
final FileChannel ch = raf.getChannel();
int fileLength = (int) ch.size();
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
fileLength);
int acc = 0;
long total = 0;
while (mb.hasRemaining()) {
int len = Math.min(mb.remaining(), buf.length);
mb.get(buf, 0, len);
for (int i = 0; i < len; i++)
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
total += acc;
acc = 0;
}
}
ch.close();
raf.close();
return total;
}
这似乎可以改善一点:我们现在 19.0秒。我们还以个人最好的成绩再创新一秒!
建议的答案之一涉及使用多个核心。我有点惭愧,我没有想过!
因为假设它是一个受I / O限制的问题而得到了一些支持。鉴于I / O的结果,这似乎有点苛刻!无论如何,当然值得一试。
我们将使用fork / join执行此操作。这里有一个类来表示文件的一部分计算结果,记住左边可能有部分结果(如果我们从数字的一半开始),右边的部分结果(如果缓冲区在数字中途完成)。该类还有一种方法允许我们将两个这样的结果粘合在一起,形成两个相邻子任务的组合结果。
private class SumTaskResult {
long subtotal;
int leftPartial;
int leftMulCount;
int rightPartial;
public void append(SumTaskResult rightward) {
subtotal += rightward.subtotal + rightPartial
* rightward.leftMulCount + rightward.leftPartial;
rightPartial = rightward.rightPartial;
}
}
现在关键位:计算结果的RecursiveTask
。对于小问题(少于64个字符),它调用computeDirectly()
来计算单个线程中的结果;对于较大的问题,它分成两部分,在单独的线程中解决两个子问题,然后结合结果。
private class SumForkTask extends RecursiveTask<SumTaskResult> {
private byte buf[];
// startPos inclusive, endPos exclusive
private int startPos;
private int endPos;
public SumForkTask(byte buf[], int startPos, int endPos) {
this.buf = buf;
this.startPos = startPos;
this.endPos = endPos;
}
private SumTaskResult computeDirectly() {
SumTaskResult result = new SumTaskResult();
int pos = startPos;
result.leftMulCount = 1;
while ((buf[pos] >= 48) && (buf[pos] <= 57)) {
result.leftPartial = result.leftPartial * 10 + buf[pos] - 48;
result.leftMulCount *= 10;
pos++;
}
int acc = 0;
for (int i = pos; i < endPos; i++)
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
result.subtotal += acc;
acc = 0;
}
result.rightPartial = acc;
return result;
}
@Override
protected SumTaskResult compute() {
if (endPos - startPos < 64)
return computeDirectly();
int mid = (endPos + startPos) / 2;
SumForkTask left = new SumForkTask(buf, startPos, mid);
left.fork();
SumForkTask right = new SumForkTask(buf, mid, endPos);
SumTaskResult rRes = right.compute();
SumTaskResult lRes = left.join();
lRes.append(rRes);
return lRes;
}
}
请注意,这是在byte[]
上运行,而不是整个MappedByteBuffer
。原因是我们希望保持磁盘访问顺序。我们将采用相当大的块,fork / join,然后移动到下一个块。
这是执行此操作的方法。请注意,我们已经将缓冲区大小推高到1MB(之前是次优的,但在这里似乎更明智)。
private long sumBinaryForwardMapForked() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
ForkJoinPool pool = new ForkJoinPool();
byte buf[] = new byte[1 * 1024 * 1024];
final FileChannel ch = raf.getChannel();
int fileLength = (int) ch.size();
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
fileLength);
SumTaskResult result = new SumTaskResult();
while (mb.hasRemaining()) {
int len = Math.min(mb.remaining(), buf.length);
mb.get(buf, 0, len);
SumForkTask task = new SumForkTask(buf, 0, len);
result.append(pool.invoke(task));
}
ch.close();
raf.close();
pool.shutdown();
return result.subtotal;
}
现在这里是令人痛苦的摧毁:这个漂亮的多线程代码现在需要 32.2秒。为何这么慢?我花了很长时间调试这个,假设我做了一些非常错误的事情。
原来只需要一个小调整。我认为小问题和大问题之间的64的门槛是合理的;事实证明这完全是荒谬的。
这样想。子问题的大小完全相同,因此它们应该在几乎相同的时间内完成。因此,除了可用的处理器之外,没有必要分成更多的部分。在我使用的机器上,只有两个核心,下降到64的阈值是荒谬的:它只会增加更多的开销。
现在你不想限制它,所以它只使用两个核心,即使有更多可用的核心。也许正确的做法是在运行时找出处理器的数量,然后分成很多部分。
在任何情况下,如果我将阈值更改为512KB(缓冲区大小的一半),它现在以 13.3秒完成。降低到128KB或64KB将允许使用更多内核(分别最多8或16个),并且不会显着影响运行时。
多线程确实会产生很大的不同。
这是一段相当漫长的旅程,但我们开始的时候花了92.9秒,我们现在已经下降到13.3秒了......那是七倍的速度原始代码。并且这不是通过改进渐近(大哦)时间复杂度来实现的,这种复杂性从一开始就是线性的(最优的)......这一直都是关于改进常数因素。
美好的一天工作。
我想我应该尝试下次使用GPU ......
我使用以下代码生成随机数,我运行并重定向到文件。显然,我不能保证你最终得到与我有完全相同的随机数:)
public static void genRandoms() {
Random r = new Random();
for (int i = 0; i < 100000000; i++)
System.out.println(r.nextInt(1000000000));
}
答案 0 :(得分:11)
您的主要瓶颈是文件IO。解析和累加数字不应该对算法有所贡献,因为可以在文件I / O等待磁盘时在单独的线程中完成。
几年前,我研究过如何以最快的方式从文件中读取文件,并提出了一些很好的建议 - 我将其作为扫描程序实现如下:
// 4k buffer size.
static final int SIZE = 4 * 1024;
static byte[] buffer = new byte[SIZE];
// Fastest because a FileInputStream has an associated channel.
private static void ScanDataFile(Hunter p, FileInputStream f) throws FileNotFoundException, IOException {
// Use a mapped and buffered stream for best speed.
// See: http://nadeausoftware.com/articles/2008/02/java_tip_how_read_files_quickly
final FileChannel ch = f.getChannel();
long red = 0L;
do {
final long read = Math.min(Integer.MAX_VALUE, ch.size() - red);
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, red, read);
int nGet;
while (mb.hasRemaining() && p.ok()) {
nGet = Math.min(mb.remaining(), SIZE);
mb.get(buffer, 0, nGet);
for (int i = 0; i < nGet && p.ok(); i++) {
p.check(buffer[i]);
//size += 1;
}
}
red += read;
} while (red < ch.size() && p.ok());
// Finish off.
p.close();
ch.close();
f.close();
}
您可能希望在测试速度之前调整此技术,因为它正在使用名为Hunter
的接口对象来搜索数据。
正如您所看到的,这些建议是在2008年推出的,从那以后Java已经有了很多改进,所以这可能无法提供改进。
我没有对此进行测试,但这应该适合您的测试并使用相同的技术:
class Summer {
long sum = 0;
long val = 0;
public void add(byte b) {
if (b >= '0' && b <= '9') {
val = (val * 10) + (b - '0');
} else {
sum += val;
val = 0;
}
}
public long getSum() {
return sum + val;
}
}
private long sumMapped() throws IOException {
Summer sum = new Summer();
FileInputStream f = new FileInputStream(file);
final FileChannel ch = f.getChannel();
long red = 0L;
do {
final long read = Math.min(Integer.MAX_VALUE, ch.size() - red);
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, red, read);
int nGet;
while (mb.hasRemaining()) {
nGet = Math.min(mb.remaining(), SIZE);
mb.get(buffer, 0, nGet);
for (int i = 0; i < nGet; i++) {
sum.add(buffer[i]);
}
}
red += read;
} while (red < ch.size());
// Finish off.
ch.close();
f.close();
return sum.getSum();
}
答案 1 :(得分:9)
为什么这么快?
创建一个字符串比一些小数学要贵得多。
使用MappedByteBuffer帮助我们可以做得更好吗?
一点点,是的。它是我用的。它将内存保存到内存副本。即不需要byte []。
我觉得调用从缓冲区读取方法的开销会降低速度,
如果方法简单,则会内联这些方法。
尤其是从缓冲区向后阅读时。
它不会变慢,事实上解析前进更简单/更快,因为你使用一个*
而不是两个。
向前读取文件而不是向后读取文件会更好吗,但仍然向后扫描缓冲区?
我不明白为什么你需要向后阅读。
想法是你读取文件的第一个块,然后向后扫描,但最后丢弃半个数字。然后,当您读取下一个块时,您可以设置偏移量,以便从您丢弃的数字的开头读取。
听起来不必要的复杂。我会一次性读取整个文件中的内存映射。除非文件大小超过2 GB,否则无需使用块。即便如此,我会一次性阅读。
我有什么想法可以产生重大影响吗?
如果数据在磁盘缓存中,它将比其他任何东西都更有意义。
答案 2 :(得分:4)
您可以使用更大的缓冲区大小,以及更快的编码到String(到Unicode)。
BufferedReader br = new BufferedReader(new InputStreamReader(
new FileInputStream(file), StandardCharsets.US_ASCII),
1_024_000_000);
使用二进制InputStream / RandomAccessFile消除String使用的方法是值得的。
如果源文件是压缩,那么它也可能会很好。在Unix下,我们会选择gzip格式,其中xxx.txt.gz
解压缩到xxx.txt
。这可以通过GZipInputStream
读取。
它具有整体加速进出服务器目录的文件传输的优点。
答案 3 :(得分:3)
我认为还有另一种方法可以做到这一点。
这是经典的多进程编程问题。在C语言中有库MPI可以解决这类问题。
它的想法是将整数列表分成4个部分,每个部分由不同的过程相加。完成后,过程总结在一起。
在java中,这可以通过线程(伪并行)和java并发来完成。
例如,4个不同的线程汇总了列表的4个不同部分。最后他们总结在一起。
电话公司使用Grid Computers进行这种并行编程技术来总结他们的交易。
此处唯一的问题(瓶颈)是IO操作。阅读文件需要很长时间。如果以某种方式你可以让多个线程读取不同的部分文件... 这是一个非常复杂的方法,我认为这不会有太大的好处,因为磁盘不会因为许多线程使用而更快地旋转,但还有其他技术可以做类似的事情。您可以在此处详细了解此信息:Access File through multiple threads和此处Reading a single file with Multiple Thread: should speed up?
答案 4 :(得分:2)
来源:http://nadeausoftware.com/articles/2008/02/java_tip_how_read_files_quickly
要获得最佳的Java读取性能,需要记住四件事:
- 通过一次读取一个数组来最小化I / O操作,而不是一次读取一个字节。一个8K字节的阵列是一个很好的大小。
- 通过一次获取数据数组来最小化方法调用,而不是一次获取一个字节。使用数组索引来获取数组中的字节数。
- 如果您不需要线程安全,请最小化线程同步锁。要么对线程安全类进行较少的方法调用,要么使用 一个非线程安全的类,如FileChannel和MappedByteBuffer。
- 最大限度地减少JVM / OS,内部缓冲区和应用程序阵列之间的数据复制。将FileChannel与内存映射一起使用,或直接使用 或包裹数组ByteBuffer。
答案 5 :(得分:2)
基于this comment:“简单地总结所有字节更快”,我提出了接受答案的变体。
接受的答案建议将问题分解成块,使用多线程计算每个吸盘的总和,并在最后将它们加在一起。
这个想法可用于减少向后扫描中的乘法次数O(1),无需任何表查找和没有线程(或将其与线程组合)。只需利用乘法分配加法的方式,将所有的数字加到一个累加器中,将数字加入一个单独的数字,将数百和数千加到自己的累加器中。这不需要任何乘法。
组合来自多个线程的结果的reduce步骤也可以使用per-place累加器来完成。计算总数的最后一步将需要乘法(或利用10只设置了两位并使用位移和加法的事实),但只有9次乘法就足够了。
答案 6 :(得分:1)
这里有几个问题。
readLine()
的任何解决方案都将创建字符串。我的解决方案:
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file), 8*1024*1024/2);
long total = 0;
int i;
while ((i = bis.read()) != -1)
{
byte b = (byte)i;
long number = 0;
while (b >= '0' && b <= '9')
{
number = number*10+b-'0';
if ((i = bis.read()) == -1)
break;
b = (byte)i;
}
total += number;
}