Context
I was looking at Shell script to count chess game outcomes, where the run time of my answer is dominated by the grep
command which selects lines beginning with [Result
. I reckoned that a special-purpose filter could be much faster, but still general enough to be useful for similar problems:
- It doesn't need a full regexp engine, but unlike
grep -F
, it only needs to look at the start of each line. - The order in which the lines are emitted doesn't matter (as long as they are intact - not intercut with parts of other lines).
- We don't need to maintain bookkeeping for the lines, as we are not printing any location information.
- We only need to read from regular files which can be memory-mapped.
The result is this program, which can be executed in either of two ways: with a list of files following the prefix to match, or with the list of files supplied on standard input - as emitted by find -print0
.
The code
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <mutex>
#include <iostream>
#include <print>
#include <string_view>
#include <vector>
#include <sys/uio.h>
#include <boost/interprocess/file_mapping.hpp>
#include <boost/interprocess/mapped_region.hpp>
// View that chunks a std::string_view into its constituent lines.
// The underlying content is expected to represent a text file -
// comprising complete lines, each ending with newline.
class lines_view
{
char const *const first;
char const *const last;
public:
// The passed string view must remain alive until after the last
// access through this object's iterators.
lines_view(std::string_view sv)
: first{sv.data()},
last{std::invoke([sv]{
// Last iterator points to the character after the final
// newline - usually same as `sv.end()`. If the text file is
// malformed(missing its newline), the incomplete final line
// is excluded.
if (auto last_nl = sv.rfind('\n'); last_nl == std::string_view::npos) {
return sv.data();
} else {
return sv.data() + last_nl + 1;
}
})}
{}
struct line_iterator
{
// Can't be forward_iterator because we return a string-view
// rather than a real reference. But it does provide the
// multipass guarantee like forward iterators.
using iterator_category = std::input_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = std::string_view;
using pointer = value_type const*;
using reference = value_type;
char const* p; // start of line
mutable char const* nl = nullptr; // ending newline, once located
// lazy accessor - must not be called on the end iterator
auto *get_nl() const
{
return nl ? nl : (nl = std::strchr(p, '\n') + 1);
}
public:
bool operator==(const line_iterator& other) const {
return p == other.p;
}
// The returned string view is valid for as long as the
// underlying string view that was passed to lines_view
// constructor.
value_type operator*() const
{
return {p, static_cast<std::string_view::size_type>(get_nl() - p)};
}
line_iterator& operator++()
{
p = get_nl();
nl = nullptr;
return *this;
}
line_iterator operator++(int)
{
auto it = *this;
++*this;
return it;
}
};
auto begin() const
{
return line_iterator{first};
}
auto end() const
{
return line_iterator{last};
}
};
// This might be overkill, as I've never seen writev() write less than
// requested. But it's certainly permitted to.
static ssize_t writev_all(int fd, iovec *outputs, int count)
{
ssize_t total = 0;
while (count > 0) {
static auto const max_iov = static_cast<int>(sysconf(_SC_IOV_MAX));
auto n = max_iov > 0 && count > max_iov ? max_iov : count;
auto w_ret = writev(fd, outputs, n);
if (w_ret < 0) {
return w_ret;
}
total += w_ret;
auto written = static_cast<std::size_t>(w_ret);
for (; count > 0 && outputs->iov_len <= written; ++outputs, --count) [[likely]] {
written -= outputs->iov_len;
}
if (count > 0 && written > 0) [[unlikely]] {
outputs->iov_base = static_cast<char*>(outputs->iov_base) + written;
outputs->iov_len -= written;
}
}
return total;
}
static int grep_single_file(std::string_view const prefix,
char const *const filename)
{
namespace bi = boost::interprocess;
// This is the only function that writes to standard output stream,
// so we control the access here.
static std::mutex out_mutex;
std::vector<iovec> outputs = {};
try {
bi::file_mapping fm(filename, bi::read_only);
bi::mapped_region mr(fm, bi::read_only);
std::string_view sv(static_cast<char const*>(mr.get_address()), mr.get_size());
for (auto line: lines_view(sv)) {
if (line.starts_with(prefix)) [[unlikely]] {
// iovec has writable char* for use with readv() - treated as const by writev()
outputs.emplace_back(const_cast<char*>(line.data()), line.size());
}
}
std::unique_lock lock{out_mutex};
if (writev_all(STDOUT_FILENO, outputs.data(), static_cast<int>(outputs.size())) < 0) {
perror("writev");
return EXIT_FAILURE;
}
} catch (std::exception& e) {
std::println(stderr, "{}: {}", filename, e.what());
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
static auto& read_nul_terminated_line(std::string& filename)
{
// This is the only function that reads from standard input stream,
// so we control the access here.
static std::mutex in_mutex;
std::unique_lock lock{in_mutex};
return std::getline(std::cin, filename, '\0');
}
int main(int argc, char **argv)
{
if (argc < 2) {
std::println(stderr, "Usage: {} PREFIX [FILE...]", argv[0] ? argv[0] : "program");
return EXIT_FAILURE;
}
std::string_view const prefix(argv[1]);
// Any thread may declare failure
std::atomic<int> status = EXIT_SUCCESS;
if (argc == 2) {
// Filenames are supplied on stdin.
#pragma omp parallel
{
std::string filename;
while (status == EXIT_SUCCESS && read_nul_terminated_line(filename)) {
if (grep_single_file(prefix, filename.c_str())) {
status = EXIT_FAILURE;
}
}
}
} else {
// argc > 2: Filenames are supplied as arguments.
// Dynamic schedule required because files vary in length.
// GCC's "guided,1" over-populates first thread: see
// <URL: https://stackoverflow.com/a/43047074/4850040 >
#pragma omp parallel for schedule(dynamic,1)
for (int i = 2; i < argc; ++i) {
if (status == EXIT_SUCCESS) {
char const *const fname = argv[i];
if (grep_single_file(prefix, fname)) {
status = EXIT_FAILURE;
}
}
}
}
// Report errors found during parallel loop (where we couldn't
// return directly).
return status;
}
Input data
I used input data from ChessData on GitHub (Warning: 3.6 GB), which contains around 10 million matching lines across 7.2 GB of input text. Some of these files contain single games, but many are concatenations of hundreds or thousands. Here's a simple order-of-magnitude distribution of the file sizes:
$ find ChessData/ -type f -name '*.pgn' -exec stat -c '%s' {} + |
> sort -n | tr '[0-9]' x | uniq -c
8 xxx
107 xxxx
916 xxxxx
723 xxxxxx
1310 xxxxxxx
144 xxxxxxxx
Functional test
I made sure it reliably feeds comparable input to the AWK script in the linked question. The output is the same for reading file names from stdin or reading them as arguments, compared to GNU grep
used as the baseline.
find ChessData/ -type f -name '*.pgn' -print0 | ./78086a '[Result ' |./78086.awk
find ChessData/ -type f -name '*.pgn' -exec ./78086a '[Result ' {} + |./78086.awk
find ChessData/ -type f -name '*.pgn' -exec grep -h '^\[Result ' {} + |./78086.awk
10316025 3956979 3035935 3323111
10316025 3956979 3035935 3323111
10316025 3956979 3035935 3323111
Performance
I compiled with GCC 14, tuned for Intel i7-3770 with -O3 -march
.
I ran the same three instances, with a pipe to cat to avoid grep
spotting that it is being used as a test and thus turning on -q
- it's in all three commands for fairness.
time find ChessData/ -type f -name '*.pgn' -print0 | ./78086a '[Result ' | cat >/dev/null
time find ChessData/ -type f -name '*.pgn' -exec ./78086a '[Result ' {} + | cat >/dev/null
time find ChessData/ -type f -name '*.pgn' -exec grep -h '^\[Result ' {} + | cat >/dev/null
After a couple of runs (populating file cache), the time stabilised; here's a representative run:
real 0m0.991s
user 0m4.236s
sys 0m1.757s
real 0m0.981s
user 0m4.229s
sys 0m1.695s
real 0m5.911s
user 0m5.035s
sys 0m1.009s
As you can see, we've taken nearly 20% off the processing (user) time, but more importantly, the time to completion (real) is reduced by a factor of around 6. On my system, it saturated three cores - parallelising further didn't increase throughput any more.
Ideas not used
In the filenames-as-arguments path, I tried sorting the files by size, as reported by stat()
, largest first, in the hope that this would reduce straggler effect (as some of the inputs are many megabytes, comprising multiple games). And in the filenames-via-stdin path, doing something similar with a std::priority_queue
between filename-reading thread and grep workers. In both cases, this produced no measurable benefit.
find ChessData/ -type f -name '*.pgn'
takes. The timing might be more controlled if the output offind
was stored in a text file and the text file was used for timing. \$\endgroup\$find
alone ran in about 40ms (with files in cache), of which 0ms is user-space time. In other words, insignificantly small proportion of the time. \$\endgroup\$