[英]Fastest way to sum integers in text file
假設您有一個大的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秒 。
受到對這個問題的評論的啟發,我嘗試不創建一個新的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的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));
}
你的主要瓶頸是文件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();
}
為什么這么快?
創建一個String比一個小數學要昂貴得多。
我們可以使用MappedByteBuffer幫助做得更好嗎?
一點點,是的。 它是我用的。 它將內存保存到內存副本。 即不需要byte []。
我有一種感覺,調用從緩沖區讀取方法的開銷會降低速度,
如果它們很簡單,那么這些方法就會被內聯。
特別是從緩沖區向后讀時。
它不會更慢,實際上解析前進更簡單/更快,因為你使用一個*
而不是兩個。
向前讀取文件而不是向后讀取文件會更好嗎,但仍然向后掃描緩沖區?
我不明白為什么你需要向后閱讀。
想法是你讀取文件的第一個塊,然后向后掃描,但最后丟棄半個數字。 然后,當您讀取下一個塊時,設置偏移量,以便從您丟棄的數字的開頭讀取。
聽起來不必要的復雜。 我會一次性讀取整個文件中的內存映射。 除非文件大小超過2 GB,否則無需使用塊。 即便如此,我會一次性閱讀。
還有什么我沒有想到的可以產生重大影響的東西嗎?
如果數據在磁盤緩存中,它將比其他任何東西產生更大的差異。
您可以使用更大的緩沖區大小,以及更快的編碼到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
可以讀取。 它具有整體加速進出服務器目錄的文件傳輸的優點。
我認為還有另一種方法可以做到這一點。
這是經典的多進程編程問題。 在C語言中有庫MPI可以解決這類問題。
它的想法是將整數列表分成4個部分,每個部分由不同的過程相加。 完成后,將過程匯總在一起。
在java中,這可以通過線程(偽並行)和java並發來完成。
例如,4個不同的線程匯總了列表的4個不同部分。 最后,他們總結在一起。
電話公司使用Grid Computers來做這種並行編程技術來總結他們的交易。
這里唯一的問題(瓶頸)是IO操作。 閱讀文件需要很長時間。 如果以某種方式你可以讓多個線程讀取文件的不同部分...這是非常復雜的方法,我認為這不會有太大的好處,因為磁盤不會旋轉得更快只是因為它被許多線程使用,但有做類似事情的其他技術。 你可以在這里閱讀更多相關信息: 通過多個線程訪問文件 ,在這里用多線程讀取一個文件:應該加快速度嗎?
資料來源: http : //nadeausoftware.com/articles/2008/02/java_tip_how_read_files_quickly
要獲得最佳的Java讀取性能,需要記住以下四點:
- 通過一次讀取一個數組來最小化I / O操作,而不是一次讀取一個字節。 一個8K字節的陣列是一個很好的大小。
- 通過一次獲取數據數組來最小化方法調用,而不是一次獲取一個字節。 使用數組索引來獲取數組中的字節數。
- 如果不需要線程安全,請最小化線程同步鎖。 對線程安全類進行較少的方法調用,或者使用非線程安全的類,如FileChannel和MappedByteBuffer。
- 最大限度地減少JVM / OS,內部緩沖區和應用程序陣列之間的數據復制。 將FileChannel與內存映射一起使用,或使用直接或包裝的數組ByteBuffer。
基於這個評論 :“簡單地總結所有字節更快”,我提出了一個接受答案的變體。
接受的答案建議將問題分解為塊,使用多線程計算每個卡盤的總和,並在最后將它們加在一起。
這個想法可用於減少向后掃描中的乘法次數O(1),無需任何表查找和沒有線程(或將其與線程組合)。 簡單地利用乘法分配加法的方式,將所有的數字加到一個累加器中,將數字加到一個單獨的數字中 ,數百和數千加到它們自己的累加器中。 這不需要任何乘法。
還可以使用每個位置累加器來完成組合來自多個線程的結果的減少步驟。 計算總數的最后一步將需要乘法(或利用10只設置了兩位並使用位移和加法的事實),但只有9次乘法就足夠了。
這里有幾個問題。
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;
}
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.