head: head_lines(): memchr() instead of manual byte-by-byte work - #347
roman-zilka wants to merge 4 commits into
Conversation
Finding EOL using memchr() is faster than a byte-by-byte check in a loop. Benchmark results (Linux, x86_64, gcc 15.3.0, total time for all files in the Linux kernel sourcecode, parameter = number of lines): head_lines_original(5): 150 ms head_lines_original(10): 154 ms head_lines_original(100): 228 ms head_lines_original(1000): 472 ms head_lines_original(10000): 639 ms head_lines(5): 146 ms head_lines(10): 148 ms head_lines(100): 196 ms head_lines(1000): 359 ms head_lines(10000): 457 ms Note that there are only 9569 files with >=1000 lines and 222 files with >=10000 lines. See Github PR for the benchmark sourcecode and details. Furthermore, redundant lines_to_write>0 checks have been removed. No AI assistance.
|
Benchmark environment:
Benchmark sourcecode: #include <unistd.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <fcntl.h>
#include <stdbool.h>
#include <time.h>
#include <ftw.h>
void xwrite_stdout(char const *buffer, size_t n_bytes)
{
if (n_bytes > 0 && fwrite (buffer, 1, n_bytes, stdout) < n_bytes) abort();
}
bool head_lines(const char *const filename, const int fd, uintmax_t lines_to_write) {
(void)filename;
char buffer[BUFSIZ & 0xffff];
const char *p, *end;
int_fast32_t bytes_read;
if (! lines_to_write) return true;
while ((bytes_read = read(fd, buffer, sizeof(buffer)))) {
if (bytes_read < 0) return false;
end = buffer + bytes_read;
for (p=memchr(buffer, '\n', bytes_read); p; p=memchr(p, '\n', end-p)) {
++p;
if (! --lines_to_write) {
//fprintf(stderr, "%d\n", (int)(-(end - p)));
xwrite_stdout(buffer, p-buffer);
return true;
}
}
assert(lines_to_write);
xwrite_stdout(buffer, bytes_read);
}
return true;
}
bool head_lines_orig(char const *filename, int fd, uintmax_t lines_to_write)
{
(void)filename;
char buffer[BUFSIZ];
while (lines_to_write)
{
ssize_t bytes_read = read (fd, buffer, BUFSIZ);
size_t bytes_to_write = 0;
if (bytes_read < 0) return false;
if (bytes_read == 0) break;
while (bytes_to_write < (size_t)bytes_read)
if (buffer[bytes_to_write++] == '\n' && --lines_to_write == 0) {
//fprintf(stderr, "%d\n", (int)((ssize_t)bytes_to_write-bytes_read));
break;
}
xwrite_stdout(buffer, bytes_to_write);
}
return true;
}
int cb(const char *path, const struct stat *meta, int flags, struct FTW *info) {
(void)meta; (void)info;
if (flags == FTW_NS) return 1;
if (flags == FTW_F) {
int fd = open(path, O_RDONLY);
if (fd < 0) abort();
head_lines(path, fd, 5);
close(fd);
}
return 0;
}
int main() {
struct timespec ts1, ts2;
if (clock_gettime(CLOCK_MONOTONIC, &ts1)) abort();
if (nftw("/tmp/linux-7.2.4", cb, 1024, FTW_PHYS)) abort();
if (clock_gettime(CLOCK_MONOTONIC, &ts2)) abort();
if (ts2.tv_nsec >= ts1.tv_nsec)
fprintf(stderr, "%d s %ld ns\n", (int)(ts2.tv_sec-ts1.tv_sec), ts2.tv_nsec-ts1.tv_nsec);
else fprintf(stderr, "%d s %ld ns\n", (int)(ts2.tv_sec-ts1.tv_sec-1), ts2.tv_nsec+(1000000000-ts1.tv_nsec));
return 0;
} |
|
I have read the Copyright assignment section in |
|
Interesting. We'll have a look at this next week after the imminent release. |
Splitting commits doesn't get around assignment, Note assignment can be done electronically. |
glibc carries ISA-optimized string functions itself. I imagine they are meticulously optimized. I wonder how EDIT: Oh, |
|
@pixelb Regarding It sort of makes the inline assembly unnecessary as long as you compile with architecture-specific options, which distributions typically don't do. But it should be helpful for architectures without inline assembly implementations. I can adjust it to fit in coreutils post-release for your reference. |
glibc To avoid that, GNU The faster way to do it is just to count how many newlines are in the buffer, which is what the inline assembly versions do. Here was the generic, non-assembly version that @Larhzu wrote, which I mentioned earlier: linecount3.c. Assuming you compile with a recent GCC or Clang version and use the correct architecture options, it should perform as well as or better than the inline assembly versions. It will need some experimenting to see if that can be applied to |
I remember seeing an algorithm resembling your
It's to avoid checking individual bytes in a loop, not
It is fast (also Zen 5), just a smidge slower than the AVX512 version of
That's not an obstacle: EDIT: Ah, now I see it, the ISA-optimized routine is a separated function with reading, there's no inlining. |
The basic idea is ancient, so I assume there are many implementations with the same idea. For me the main news was how amazingly compilers vectorize such code nowadays. :-) With For testing, I commented out AVX-512 and AVX2 from I also ran I built linecount3.c three times: The test input was all files from a Linux source tree duplicated five times: 8203 million bytes, 219 million newlines (2.7 %). The branch_miss_rate column is from
The performance of the second and third
Every
With short lines, the function call and the startup overhead of I further modified wc.c so that it always calls I tested this file with your benchmark too. As expected from the previous paragraph, I haven't thought much what |
|
Turns out that bool head_lines_rawmemchr(const char filename[const static 1], const int fd, uintmax_t lines_to_write) {
(void)filename;
assert(fd >= 0);
char buffer[(BUFSIZ&0xffff)+1], *end;
const char *p;
int_fast32_t bytes_read;
if (! lines_to_write) return true;
while ((bytes_read = read(fd, buffer, sizeof(buffer)-1))) {
if (bytes_read < 0) return false;
end = buffer + bytes_read;
*end = '\n';
p = rawmemchr(buffer, '\n');
while (p != end) {
++p;
if (! --lines_to_write) {
//fprintf(stderr, "%d\n", (int)(-(end - p)));
xwrite_stdout(buffer, p-buffer);
return true;
}
p = rawmemchr(p, '\n');
}
assert(lines_to_write);
xwrite_stdout(buffer, bytes_read);
}
return true;
} |
|
I used the line length guessing method from bool head_lines_memchr_mixed(const char filename[const static 1], const int fd, uintmax_t lines_to_write) {
(void)filename;
assert(fd >= 0);
char buffer[BUFSIZ & 0xffff];
const char *p, *end;
int_fast32_t bytes_read;
uintmax_t lines_to_write_last;
#define long_line_threshold 4
// 36 = avg line length in the distribution of linux-7.2.4
bool long_lines = 36 >= long_line_threshold;
if (! lines_to_write) return true;
while ((bytes_read = read(fd, buffer, sizeof(buffer)))) {
if (bytes_read < 0) return false;
p = buffer;
end = buffer + bytes_read;
lines_to_write_last = lines_to_write;
if (long_lines) {
while ((p = memchr(p, '\n', end-p))) {
++p;
if (! --lines_to_write) break;
}
}
else while ((*p++ != '\n' || --lines_to_write) && p < end) ;
if (! lines_to_write) {
//fprintf(stderr, "%d\n", (int)(-(end - p)));
assert(p);
xwrite_stdout(buffer, p-buffer);
return true;
}
xwrite_stdout(buffer, bytes_read);
long_lines = bytes_read/(uintmax_t)long_line_threshold >= lines_to_write_last-lines_to_write;
if (long_lines) ++nr_ll; else ++nr_sl;
}
return true;
}As compared with
Instead of the kernel sources, a single file with 300000000 lines, each 10 chars long:
A single file with 300000000 lines, each 15 chars long:
A single file with 300000000 lines, each 20 chars long:
Conclusion: |
Estimate the length of lines in input and choose a method of finding EOLs appropriately to increase performance: memchr() for long lines, manual byte-wise search for short lines. Long-short threshold = 15, experimentally: github.com/coreutils/pull/347#issuecomment-5653011937 Initial guess = long lines, based on Linux sources and /etc: coreutils#351
|
By the way, this goes against my findings.
I built coreutils with no custom CFLAGS, just |
|
FYTI note coreutils honors GLIBC_TUNABLES |
I used a single big file to test because then the file I/O overhead is much smaller. If I use a command like the following, then I get results that are comparable to yours. With one big file, the syscall overhead is still significant. For me, It's because The AVX-512 version of I created a version based on linecount3.c that counts newlines 128 bytes at a time, and once enough newlines have been found, it does the final search with a byte-by-byte method: bool head_lines_masking128(
const char filename[const static 1], const int fd,
uintmax_t lines_to_write) {
union {
uint8_t u8[BUFSIZ & ~7U];
uint64_t u64[(BUFSIZ & ~7U) / 8];
} buf;
(void)filename;
assert(fd >= 0);
if (lines_to_write == 0)
return true;
while (true) {
const ssize_t read_size = read(fd, buf.u8, sizeof(buf));
if (read_size == 0)
return false;
const size_t buf_size = read_size;
const size_t CHUNK_SIZE = 128;
const size_t limit = buf_size & ~(CHUNK_SIZE - 1);
const size_t limit8 = limit / 8;
for (size_t i = 0; i < limit8; i += CHUNK_SIZE / 8) {
const uint64_t NEWLINES8 = 0x0A0A0A0A0A0A0A0A;
const uint64_t HIGH_MASK = 0x8080808080808080;
const uint64_t LOW_MASK = 0x0101010101010101;
size_t count0 = 0;
size_t count1 = 0;
for (size_t k = 0; k < CHUNK_SIZE / 8; k += 2) {
uint64_t a = buf.u64[i + k] ^ NEWLINES8;
uint64_t b = buf.u64[i + k + 1] ^ NEWLINES8;
count0 += ((((a | HIGH_MASK) - LOW_MASK) | a) & HIGH_MASK) >> 7;
count1 += ((((b | HIGH_MASK) - LOW_MASK) | b) & HIGH_MASK) >> 7;
}
count0 += count1;
count0 = CHUNK_SIZE - ((count0 * LOW_MASK) >> 56);
if (count0 >= lines_to_write) {
i *= 8;
do {
lines_to_write -= buf.u8[i++] == 0x0A;
} while (lines_to_write > 0);
xwrite_stdout((const char *)buf.u8, i);
return true;
}
lines_to_write -= count0;
}
for (size_t i = limit; i < buf_size; ) {
lines_to_write -= buf.u8[i++] == 0x0A;
if (lines_to_write == 0) {
xwrite_stdout((const char *)buf.u8, i);
return true;
}
}
xwrite_stdout((const char *)buf.u8, buf_size);
}
return true;
}With
Single file tests:
Printing the first 100,000,000 lines (or the whole file):
With long lines, memchr_mixed is the fastest. Allowing AVX2 (but not AVX-512) with With typical line lengths, masking128 seems the best on x86-64. It likely won't do so well on 32-bit processors, at least in its current form. Other 64-bit archs I didn't test. Without such testing, memchr_mixed might be a less risky choice. I don't have a clear opinion now, but hopefully others can figure out something from these results.
Thanks! It wasn't enough in this case though. I wanted to check the speed of generic |
|
|
||
| while (lines_to_write) | ||
| assert (fd >= 0); | ||
| char buffer[BUFSIZ & 0xffff]; |
There was a problem hiding this comment.
I don't understand why BUFSIZ & 0xffff is used. It becomes zero if BUFSIZ happens to be a large power of two (maybe unlikely in practice).
There was a problem hiding this comment.
Thank you, fixed. It's a quick workaround turned habit. Point is to cap buffer size, assuming a positive BUFSIZ (since at least ISO C99). Bumped to 512K for the heck of it.
There was a problem hiding this comment.
Thanks! One some OSes, 512 KiB might be too much to allocate on stack, but I don't think it matters here. I don't know any OS with a huge BUFSIZ value. It's common to see that apps want to use a larger value than BUFSIZ. OSes cannot increase BUFSIZ even if it made sense because that would be an ABI break.
There was a problem hiding this comment.
I think it is fine to trust BUFSIZ. If a system were ever defined as too large, it would probably make more sense to redefine it in Gnulib's stdio.h header. That way we don't have to think about it everywhere it gets used.
There was a problem hiding this comment.
I think it's good practice to make a sanity check on BUFSIZ and have a custom constant. Something like #if (BUFSIZ >= 512) && (BUFSIZ <= 65536) then BUFSIZ, else 4096. No more than 65536, so that there can be multiple allocations.
IO_BUFSIZE (256K) from src/ioblksize.h is intended for whole files. io_blksize() requires a prior stat(), plus it returns something >= IO_BUFSIZE.
That said, BUFSIZ's been obviously widely used with no problem.
Let me know if you want me to make a change. As it is, it can serve as a reminder of the issue.
|
This likely isn't important, but I was curious about the worst case performance of the generic version currently in src/wc.c. I tested with 4 GiB inputs. The first file makes the heuristics in the generic code always pick the wrong method for the next block: for I in $(seq 8192)
do
head -c 262144 /dev/zero
head -c 262144 /dev/zero | tr '\0' '\n'
done > testfile
Note that SSE2 It's an extreme test case, and I'm not suggesting any code changes due to this result. |
Use memchr() instead of manual byte-by-byte work in head_lines() to find EOL for a run time decrease noticeable esp. on large outputs. Benchmark results (Linux, x86_64, gcc 15.3.0, total time for all files in the Linux kernel sourcecode, parameter = number of lines):
Note that there are only 9569 files with >=1000 lines and 222 files with >=10000 lines.
Furthermore, redundant lines_to_write>0 checks have been removed.
No AI assistance.