EDIT2: There is a summary below of the findings with new improved code and run time results achieved.
Multi threaded follow up has been posted here.
I find, in my daily programming, that text-file parsing (various CSV, and ad-hoc formats etc) is still very common. When data size gets to >1MB, performance becomes a critical aspect. Reading files, parsing for separators and converting contents (often to floats or ints) can be a very slow process.
The approach was to pull in existing tools, which can help, and make them convenient, rather than to reinvent the wheel. So I have curated and written helpers for some tools to help make this process convenient while achieving very high performance.
The "Yahtzee" programming challenge shall serve as an illustrative example. Clearly this is not a real world problem, but not much imagination is required to see how it translates. Follow that link for full details, but the task is basically:
- Read ~1MB file with about ~100,000 whitespace separated ints
- Group them by hash map (most efficient?)
- Find the group with the largest sum
The code below achieves complete parse and compute in < 8ms on my machine (i7 2600 with SSD) for the provided 1MB file on github. Most of that is read & parse (~7ms). This represents about a 5x gain on the "naive" approach using <iostream>
or std::getline
parsing and converting. (For reference the output is "31415926535" as the sum of the largest group.)
Performance techniques / tricks used are:
- Use memory mapped file --
mmap
. Wrapped in an RAII convenience class. - Use a piping mentality throughout. Never accumulate data.
- Make no
std::string
and no copies. Usestd::string_view
throughout. - The
mmap
file gives aconst char*
buffer which we can parse over and access usingstd::string_view
. - Don't use
std::isnumeric
because it is locale dependent. Use an optimised replacement which assumes ASCII and has knowledge about the format. - Use
<charchonv> from_chars
because it's very fast. (Only MSVC supports floats, but on gcc/clang we could use Ryu) - Use the awesome
ska::bytell_hash_map
from here - All the
os::
... utility wrappers are my own from here. - Compiled with
clang-9 -O3
for x64. Platform is Kubuntu 19.10.
The mmap
is key at this file size. It dropped time from ~38ms to 20ms immediately. (I realise that mmap
is not optimal for smaller files, but those are "fast" anyway.)
skarupke's ska::bytell_hash_map
is also a sigificant gain on the compute side. See here for why.
Clearly mmap
is not very portable, but accepting that, does this represent about the best we can do?
Is there any other feedback about the approach or the code (including the code in os::
namespace on github link)?
EDIT: Based on some feedback, just a clarification. The 1MB is what I have found to be smallest size where this sort of approach makes sense. Of course 8ms is pretty quick. But the speedup from 40ms is still very relevant because the actual use case may involve either hundreds of such 1MB files or one much bigger file. We can make a large file with: for i in {1..1000}; do cat yahtzee-upper-1.txt >> yahtzee-upper-big.txt ; done
which gives a ~1GB file. That runs in 5.8seconds on my machine. ie the whole process scales almost perfectly linearly.
The idea is not to optimise away every last cycle given the exact nature of this task/file. Because that tends to a) quickly have diminishing returns and b) remove any re-usability. Instead I am trying to get 80% of the possible speedup by using a few big tools (mmap, charconv, ska::bytell_hashmap, ...) and then make them conveniently usable for many many different kinds of parsing tasks with minimal or no code change.
#include "flat_hash_map/bytell_hash_map.hpp"
#include "os/fs.hpp"
#include "os/str.hpp"
#include <cstdint>
#include <iostream>
#include <string>
#include <string_view>
// code extracts for from os/str.hpp for hot-path
// see github link above for complete code
namespace os::str {
namespace ascii {
inline constexpr bool isnumeric(char c) {
return (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == ',' || c == '^' ||
c == '*' || c == 'e' || c == 'E';
}
} // namespace ascii
/// ... skip
inline std::optional<std::string> trim_lower(std::string_view word) {
word = trim_if(word, ascii::isalpha);
if (!word.empty()) {
std::string output{word};
// tolower is redundant for this example, but not significant
std::transform(output.begin(), output.end(), output.begin(),
[](auto c) { return ascii::tolower(c); });
return std::optional<std::string>{output};
}
return std::nullopt;
}
template <typename ActionFunction, typename Predicate = decltype(ascii::isalpha)>
void proc_words(std::string_view buffer, const ActionFunction& action,
const Predicate& pred = ascii::isalpha) {
const char* begin = buffer.begin();
const char* curr = begin;
const char* const end = buffer.end();
while (curr != end) {
if (!pred(*curr)) {
auto maybe_word =
trim_lower(std::string_view{&*begin, static_cast<std::size_t>(curr - begin)});
if (maybe_word) action(*maybe_word);
begin = std::next(curr);
}
std::advance(curr, 1);
}
}
} // namespace os::str
// EOF os/str.hpp
// start main code
std::uint64_t yahtzee_upper(const std::string& filename) {
auto mfile = os::fs::MemoryMappedFile{filename};
auto max_total = std::uint64_t{0};
auto accum = ska::bytell_hash_map<std::uint64_t, std::uint64_t>{};
os::str::proc_words(
mfile.get_buffer(),
[&](std::string_view word) {
auto die = os::str::from_chars<std::uint64_t>(word);
auto total = accum[die] += die;
if (total > max_total) max_total = total;
},
os::str::ascii::isnumeric);
return max_total;
}
int main(int argc, char* argv[]) {
if (argc < 2) return 1;
std::cout << yahtzee_upper(argv[1]) << '\n';
return 0;
}
sync; echo 3 > /proc/sys/vm/drop_caches
between runs to clear caches, otherwise you'll be measuring the wrong thing. \$\endgroup\$mmap(MAP_POPULATE)
if your file isn't huge and you expect it to be hot in pagecache. That should wire up the page tables, avoiding soft page faults as you read it. But if it's not in RAM at all, that prevents overlapping I/O with computation on the first part of the file :/ \$\endgroup\$