Reputation: 43
I understand how to do this using std::string and std::unordered_set, however, each line and each element of the set takes up a lot of unnecessary, inefficient memory, resulting in an unordered_set and half the lines from the file being 5 -10 times larger than the file itself.
Is it possible (and how, if so) to somehow reduce memory consumption, for example, so that you can remove duplicates from a 10 gigabyte file using no more than 20 gigabytes of RAM? In this case, of course, it is necessary to do this at a speed of O(n).
Upvotes: 0
Views: 1017
Reputation: 73181
I know it's a bit late for an answer to this question, but just for fun I wrote an implementation that I think is quite memory-efficient while still being reasonably performant.
In particular, this solution runs in O(N*log(N))
time and uses (on my machine) just 360 kilobytes(!) of heap memory while de-duping a 100,000,000 line (5 gigabyte) text file that contains 99,990,000 randomly-arranged duplicate lines, and finishes in 6 minutes, 32 seconds.
Of course it does cheat a bit, in that it writes out a temporary index file to disk (the index contains a hash value for each line in the input file, as well as that line's position within the input file). The index file needs 16 bytes for each text line, so it came out to ~1.4GB in my test.
To do the de-duplication, the program mmap()
's the index file into RAM, sorts its contents by hash code, then scans the index and invalidates any now-adjacent entries with the same hash code that refer to the same string in the input file.
After that it, re-sorts the index file by byte-offset, and then iterates through the index one more time to generate the de-duped output file.
Output from my test run (on a 2018 Intel Mac Mini) is as follows:
Jeremys-Mac-mini-2:~ jaf$ time ./a.out
Step 1: Read input file [big_input_file.txt] and write index file [index_file.tmp]
Step 2: mmap() index file [index_file.tmp] into RAM and sort its entries by hash-code
Step 3: Iterate through index file [index_file.tmp] and invalidate any duplicate entries
Step 4: Re-sort the index file [index_file.tmp] by byte-offset in preparation for generating output
Step 5: Write output file [big_output_file.txt]
Step 6: Delete index file and exit
Final result: Out of 100000001 lines read, 99990000 duplicate lines detected and removed.
real 6m32.800s
user 3m39.215s
sys 2m41.448s
Source code is below (I compiled it with g++ -std=c++20 -O3 ./dedup_file.cpp
):
#include <fcntl.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/mman.h>
#include <array>
#include <fstream>
#include <iostream>
#include <span>
#include <string>
using SizeTPair = std::array<size_t, 2>;
static const SizeTPair INVALID_INDEX_ENTRY = {(std::size_t)-1, (std::size_t)-1}; // special value meaning "not a valid index entry"
// Given a pointer into the contents of the input file, returns a string_view representing
// the line of text starting there. (This is necessary since we can't modify the input file
// and the lines in the input file are not NUL-terminated)
static std::string_view GetStringAtOffset(const char * inputFileMap, size_t offset)
{
if (offset == (size_t)-1) return "";
const char * s = &inputFileMap[offset];
const char * nl = strchr(s, '\n');
return nl ? std::string_view(s, nl-s) : std::string_view(s);
}
// Comparison functor to sort SizeTPairs by the text they point to
// breaks ties by sorting by line-number (so that if a duplicate line is
// detected in the text, it will always be the second instance of that line that
// is excluded from our program's output, not the first instance)
class SortIndicesByStringCompareFunctor
{
public:
SortIndicesByStringCompareFunctor(const char * inputFileMap) : _inputFileMap(inputFileMap) {/* empty */}
bool operator()(const SizeTPair & a, const SizeTPair & b) const
{
const std::string_view textA = GetStringAtOffset(_inputFileMap, a[0]);
const std::string_view textB = GetStringAtOffset(_inputFileMap, b[0]);
if (textA != textB) return (textA < textB);
return (a[1] < b[1]); // sub-sort by line number
}
private:
const char * _inputFileMap;
};
static void WriteEntryToIndexFile(std::ofstream & indexFile, const SizeTPair & entry, size_t & indexSizeItems)
{
indexFile.write(reinterpret_cast<const char *>(&entry), 2*sizeof(size_t));
indexSizeItems++;
}
int main(int, char **)
{
const char * bigInputFileName = "big_input_file.txt";
const char * indexFileName = "index_file.tmp";
const char * bigOutputFileName = "big_output_file.txt";
std::cout << "Step 1: Read input file [" << bigInputFileName << "] and write index file [" << indexFileName << "]" << std::endl;
// Step 1. Read through the big input-text file, and generate a binary
// index-file containing (for each line) that line's hash-code and also
// its location in the input file
size_t indexSizeItems = 0;
size_t inputFileSizeBytes = 0;
{
std::ifstream inputFile;
inputFile.open(bigInputFileName, std::ios_base::binary | std::ios_base::ate); // binary only so we can get valid file-offsets out of tellg()
inputFileSizeBytes = inputFile.tellg(); // get file size
inputFile.seekg(0, std::ios_base::beg); // then go back to the beginning of the file so we can read it
std::ofstream indexFile;
indexFile.open(indexFileName, std::ios_base::binary);
std::string nextLine;
while(inputFile.good())
{
const std::streampos curFileOffset = inputFile.tellg(); // do this before reading the line: record our current read-offset into the file
std::getline(inputFile, nextLine);
WriteEntryToIndexFile(indexFile, {std::hash<std::string>()(nextLine), (std::size_t)curFileOffset}, indexSizeItems);
}
// Add a final dummy-entry to the end of the index, just to force the flushing of any
// final text-line(s) in our for-loop in step (3)
WriteEntryToIndexFile(indexFile, INVALID_INDEX_ENTRY, indexSizeItems);
}
std::cout << "Step 2: mmap() index file [" << indexFileName << "] into RAM and sort its entries by hash-code" << std::endl;
// Step 2. mmap() the index-file we just generated, and sort its contents by hash-code (sub-sort by byte-offset)
const int indexFD = open(indexFileName, O_RDWR, (mode_t)0666);
if (indexFD < 0) {std::cerr << "Couldn't open() index file!" << std::endl; exit(10);}
char * indexFileMap = (char *) mmap(0, indexSizeItems*(2*sizeof(size_t)), PROT_READ | PROT_WRITE, MAP_SHARED, indexFD, 0);
if (indexFileMap == MAP_FAILED) {std::cerr << "mmap() of index file failed!" << std::endl; exit(10);}
SizeTPair * index = reinterpret_cast<SizeTPair *>(indexFileMap);
std::span<SizeTPair> indexSpan(index, index+indexSizeItems);
std::sort(std::begin(indexSpan), std::end(indexSpan));
std::cout << "Step 3: Iterate through index file [" << indexFileName << "] and invalidate any duplicate entries" << std::endl;
// Step 3. Go through the index file and invalidate any duplicate
// entries (i.e. any entries that have the same hash code and same
// underlying string as a previous entry)
const int inputFD = open(bigInputFileName, O_RDONLY, (mode_t)0666);
if (inputFD < 0) {std::cerr << "Couldn't open() input file!" << std::endl; exit(10);}
const char * inputFileMap = (const char *) mmap(0, inputFileSizeBytes, PROT_READ, MAP_SHARED, inputFD, 0);
if (indexFileMap == MAP_FAILED) {std::cerr << "mmap() of index file failed!" << std::endl; exit(10);}
size_t dupesRemoved = 0;
ssize_t runStartIdx = -1;
for (size_t i=0; i<indexSizeItems; i++)
{
SizeTPair & curEntry = index[i];
// swap to put the line number in [0] and the hash in [1], since in the future
// we will want to sort by line number and this will make it easier to do that.
std::swap(curEntry[0], curEntry[1]);
const size_t curByteOffset = curEntry[0];
const size_t curHash = curEntry[1];
if (runStartIdx >= 0)
{
if (curHash != index[runStartIdx][1])
{
// A run of identical hashes started at (runStartIdx) and ended just before (i)
if ((i-runStartIdx)>1)
{
// Re-sort the index-entries-with-identical-hashes by the strings they represent
// so that we can find and remove any duplicate-strings easily. (We have to do this
// because the same hash could, at least in principle, be associted with two different strings)
std::span<SizeTPair> duplicateHashesSpan(index+runStartIdx, index+i);
std::sort(std::begin(duplicateHashesSpan), std::end(duplicateHashesSpan), SortIndicesByStringCompareFunctor(inputFileMap));
std::string_view previousEntryTextLine;
for (size_t j=runStartIdx; j<i; j++)
{
const std::string_view curEntryTextLine = GetStringAtOffset(inputFileMap, index[j][0]);
if (curEntryTextLine == previousEntryTextLine)
{
index[j] = INVALID_INDEX_ENTRY;
dupesRemoved++;
}
previousEntryTextLine = curEntryTextLine;
}
}
runStartIdx = i;
}
}
else runStartIdx = i;
}
std::cout << "Step 4: Re-sort the index file [" << indexFileName << "] by byte-offset in preparation for generating output" << std::endl;
// Step 4. Re-sort the index file by byte-offset (note that each line's byte-offset is stored
// as the first entry in its SizeTPair now!)
std::sort(std::begin(indexSpan), std::end(indexSpan));
std::cout << "Step 5: Write output file [" << bigOutputFileName << "]" << std::endl;
// Step 5. Read through the big text file one more time, and
// write out only those lines that still exist in the index file
std::ofstream outputFile;
outputFile.open(bigOutputFileName);
for (size_t i=0; i<indexSizeItems; i++)
{
const SizeTPair & curEntry = index[i];
if (curEntry == INVALID_INDEX_ENTRY) break; // these will all have been sorted to the end so we can stop now
else outputFile << GetStringAtOffset(inputFileMap, curEntry[0]) << std::endl;
}
outputFile.close();
// Step 6. Clean up our mess and exit
std::cout << "Step 6: Delete index file and exit" << std::endl;
close(inputFD);
close(indexFD);
remove(indexFileName);
std::cout << "Final result: Out of " << (indexSizeItems-1) << " lines read, " << dupesRemoved << " duplicate lines detected and removed. " << std::endl;
return 0;
}
Upvotes: 0
Reputation: 12342
You can quickly find duplicate lines using the hash of each line as shown in other answers. But if you only store the hashes then that assumes there is no hash collision. If you use std::hash
that won't be true. You can probably get away with it if you use a good cryptographic hash.
With your input being only 10G I would suggest a different approach. Well, apart from the trivial. 10G is something you can probably just load into memory and store each line as string on modern systems.
But lets save some memory:
std::unordered_multimap<std::size_t, std::string_view> lines;
to keep track of lines already seen in the inputlines
. If the hash exists compare the line against the other lines with the same hash. If the line is unique then add it to lines
and output it.This will use 32 bytes of memory per (unique) line I think. So with short lines the memory required might be more than the input file. On the other hand with short lines there are probably a lot less unique lines.
PS: You can save memory by only storing the beginning of each line. And if you estimate the number of (unique) lines you can use a hashtable with a different collision strategy (without bins) to bring it down to 8 byte per line.
Upvotes: 2
Reputation: 59440
This code is reading the input file line by line, storing only hashes of the strings in memory. If the line was not seen before, it writes the result into the output file. If the line was seen before, it does not do anything.
It uses sparsepp to reduce the memory footprint.
Input data:
Build:
Processing:
Is that good enough? I can't tell, because your performance requirements are quite vague and you didn't give proper performance comparison data. It may depend on your machine, your data, your RAM size, your hard disk speed, ...
#include <chrono>
#include <iostream>
#include <fstream>
#include <algorithm>
#include <array>
#include <cstring>
#include <functional>
#include <random>
#include <string>
#include "spp.h"
int main()
{
std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
std::ifstream input;
std::ofstream output;
input.open("10gb.txt");
output.open("10gb_nodupes.txt");
std::string inputline;
spp::sparse_hash_map<size_t, size_t> hashes;
while (std::getline(input, inputline))
{
std::size_t hash = std::hash<std::string>{}(inputline);
if (!hashes.contains(hash))
{
output << inputline << '\n';
hashes[hash]=0;
}
}
input.close();
output.close();
std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();
std::cout << "Time difference = " << std::chrono::duration_cast<std::chrono::seconds>(end - begin).count() << "[s]" << std::endl;
std::cout << "Done";
}
Upvotes: -1
Reputation: 30579
Sure, you can do this in O(n^2) time using only the amount of memory required to hold two lines, a boolean flag, and two file offsets.
The basic algorithm would be:
This is, of course, extremely time-inefficient, but it's about as memory-efficient as you can get.
Possible C++ implementation:
std::ifstream input(inputFilePath);
std::ofstream output(outputFilePath);
std::streamoff offset_before = 0;
std::streamoff offset_after = 0;
bool found_dupe = false;
std::string line1;
while (std::getline(input, line1)) {
offset_after = input.tellg();
input.seekg(0);
std::string line2;
while (input.tellg() < offset_before && std::getline(input, line2)) {
if (line1 == line2) {
found_dupe = true;
break;
}
}
if (!found_dupe) {
output << line1 << '\n';
}
found_dupe = false;
input.seekg(offset_after);
offset_before = offset_after;
}
Upvotes: 0