Feedback: simple SIMD powered string pattern search over text files recursively

Hi,

I’m looking for some feedback on my example code and hope for some suggestions/tipps what can be done better :slightly_smiling_face:

The goal is to search all textfiles of a nested directory tree and return those which match a string pattern. For now, no complex match syntax like Regex or fuzzy matching is wanted, just plain exact matching of the given pattern. Additionally, the following aspects are important:

  • return file path on first match. It doesn’t make any difference if the file content matches the pattern once or multiple times.
  • All non plain-text files should be skipped.
  • Keep memory usage low if possible, especially heap-allocated memory.

Its the first time for me trying to write some SIMD-boosted code. My main sources for getting into it and understanding SIMD stuff were the following:

Here is my example Code:

Longer example code block incoming
// Compiled with Zig 0.16.0
// Run with `zig run simd_search.zig -- <pattern> <root-search-dir>`
const std = @import("std");

const vec_len = std.simd.suggestVectorLength(u8) orelse 8;
const Mask = @Int(.unsigned, vec_len);

pub fn main(init: std.process.Init) !void {
    const arena = init.arena.allocator();
    const io = init.io;
    var args = init.minimal.args.iterate();

    _ = args.next();

    const pattern = if (args.next()) |pat| pat else return error.NoPattern;

    const path = if (args.next()) |p| p else ".";

    const root_dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true });
    defer root_dir.close(io);

    std.log.debug("Recursivley search for pattern '{s}' in root dir '{s}'", .{ pattern, path });

    var matches: std.ArrayList([]const u8) = .empty;

    try recursivleySearchFiles(arena, root_dir, io, path, pattern, &matches);

    std.debug.print("Matched files {d}:\n", .{matches.items.len});
    for (matches.items) |match| {
        std.debug.print("- {s}\n", .{match});
    }
}

fn recursivleySearchFiles(
    arena: std.mem.Allocator,
    dir: std.Io.Dir,
    io: std.Io,
    path: []const u8,
    pattern: []const u8,
    matches: *std.ArrayList([]const u8),
) !void {
    var dir_it = dir.iterate();

    const first_char = pattern[0];
    const last_char = pattern[pattern.len - 1];
    const first_c: @Vector(vec_len, u8) = @splat(first_char);
    const last_c: @Vector(vec_len, u8) = @splat(last_char);

    walk_dir: while (try dir_it.next(io)) |entry| {
        switch (entry.kind) {
            .file => {
                try matchFileContent(arena, dir, io, path, entry.name, pattern, matches, first_c, last_c);
                continue :walk_dir;
            },
            .directory => {
                const subpath = std.fs.path.join(arena, &.{ path, entry.name }) catch entry.name;
                const subdir = try dir.openDir(io, entry.name, .{ .iterate = true });
                defer subdir.close(io);
                try recursivleySearchFiles(arena, subdir, io, subpath, pattern, matches);
                continue :walk_dir;
            },
            else => continue :walk_dir,
        }
    }
}

const FILE_SIZE_STACK = 1024;

fn matchFileContent(
    arena: std.mem.Allocator,
    dir: std.Io.Dir,
    io: std.Io,
    path: []const u8,
    file_name: []const u8,
    pattern: []const u8,
    matches: *std.ArrayList([]const u8),
    first_c: @Vector(vec_len, u8),
    last_c: @Vector(vec_len, u8),
) !void {
    var file = try dir.openFile(io, file_name, .{});
    defer file.close(io);

    var check_txt_buf: [1024]u8 = undefined;
    const i = try file.readPositionalAll(io, &check_txt_buf, 0);
    if (!isText(check_txt_buf[0..i])) return;

    const file_path = std.fs.path.join(arena, &.{ path, file_name }) catch file_name;

    const file_len = try file.length(io);

    if (file_len <= FILE_SIZE_STACK) {
        var file_buf: [FILE_SIZE_STACK]u8 = undefined;
        _ = try file.readPositionalAll(io, &file_buf, 0);
        if (findMatch(pattern, &file_buf, first_c, last_c)) |idx| {
            _ = idx;
            try matches.append(arena, file_path);
            return;
        }
    } else {
        const file_buf_alloc = try arena.alloc(u8, file_len);
        _ = try file.readPositionalAll(io, file_buf_alloc, 0);
        if (findMatch(pattern, file_buf_alloc, first_c, last_c)) |idx| {
            _ = idx;
            try matches.append(arena, file_path);
            return;
        }
    }
}

fn findMatch(
    pattern: []const u8,
    data: []const u8,
    first_c: @Vector(vec_len, u8),
    last_c: @Vector(vec_len, u8),
) ?usize {

    var pos: usize = 0;
    var rest = data.len;

    while (rest > 0) {
        if (rest < vec_len + pattern.len) {
            return std.mem.findPos(u8, data, pos, pattern);
        }

        const block_first: @Vector(vec_len, u8) = data[pos..][0..vec_len].*;
        const block_last: @Vector(vec_len, u8) = data[pos + pattern.len - 1 ..][0..vec_len].*;
        const eq_first = first_c == block_first;
        const eq_last = last_c == block_last;

        const matched = eq_first & eq_last;

        var mask: Mask = @bitCast(matched);

        if (mask != 0) {
            const bit = @ctz(mask);
            const hit = pos + bit;
            const match = data[hit .. hit + pattern.len];
            if (std.mem.eql(u8, pattern, match)) return hit;
            mask = 0;
        }

        pos += vec_len;
        rest -= vec_len;
    }

    return null;
}

fn isText(buf: []const u8) bool {
    if (buf.len == 0) return false;

    for (buf) |b| {
        if (b == 0) return false;
    }

    return true;
}

So far, the code works just fine. On a small dir with subdirs containing about 130 Markdownfiles with a total size of round about 500Kb searching for a pattern which is present in 14 files it takes about 7ms. ripgrep takes about 3.5ms.

Of course, the latter is heavily optimized and uses parallel execution etc[1] while my simple code is singe threaded and has no further optimization beside SIMD instructions.[2] Maybe its not that bad already.

However, I’d like to understand all of that stuff better and might be overlooking something really obvious since I’ve no experience with SIMD and low level programming is just a hobby for me.

Thanks already to everyone who read this and might have some feedback :heart_exclamation:


  1. ripgrep is faster than {grep, ag, git grep, ucg, pt, sift} - Andrew Gallant's Blog ↩︎

  2. Those: SIMD-friendly algorithms for substring searching ↩︎

1 Like

Oh, that’s pretty cool; I’ve wanted to try some SIMD programming myself, and I didn’t know you could use it for this. Grep is a fun program to write; I used it to practice using Io async.

I’ve put the code in a git repo / vscode workspace to make it easier for me to dig into. https://codeberg.org/vincent-dalstra/lukeflo-grep if you’re interested.

I’ll get back to you once I’ve dug into the SIMD stuff, and I’ll try to avoid nitpicking the rest, but I’ll give two tips:

First, output to stdout, not stderr: it makes it easier to pipe the results into another program such as less or wc -l (counts newlines) and compare it to grep or ripgrep. I’ve already made that change myself: https://codeberg.org/vincent-dalstra/lukeflo-grep/commit/933e401d060113a3bc18d5b017db1422226bd67b

Second, if you’re interested in performance, there are tools such as hyperfine that will do things like:

  • Warm-up runs
  • Multiple runs, giving you mean and standard deviation.
  • Break-down the results into Real-time, User and System time.

The last one is important, because any SIMD optimisations you write cannot reduce the System time; that’s time spent in kernel code, actually reading the file from the disk. User time is the CPU time spent in your program, which you can change. Note that Real-time can be shorter than User + System (if the program is multi-threaded) or longer (if the program is IO-bound).

Example output (Debug)

hyperfine --warmup=3 'zig-out/bin/zutils "main" ~/Downloads'
Benchmark 1: zig-out/bin/zutils "main" ~/Downloads
  Time (mean ± σ):     538.1 ms ±   2.7 ms    [User: 516.1 ms, System: 21.6 ms]
  Range (min … max):   534.7 ms … 541.8 ms    10 runs

Example output (ReleaseFast)

hyperfine --warmup=3 'zig-out/bin/zutils "main" ~/Downloads'
Benchmark 1: zig-out/bin/zutils "main" ~/Downloads
  Time (mean ± σ):      20.6 ms ±   0.3 ms    [User: 3.5 ms, System: 17.0 ms]
  Range (min … max):    20.1 ms …  21.7 ms    138 runs

EDIT: ripgrep:

hyperfine --warmup=3 'rg -r "main" ~/Downloads'
Benchmark 1: rg -r "main" ~/Downloads
  Time (mean ± σ):       3.3 ms ±   0.5 ms    [User: 2.3 ms, System: 1.2 ms]
  Range (min … max):     2.6 ms …   5.5 ms    873 runs
 
  Warning: Command took less than 5 ms to complete. Note that the results might be inaccurate because hyperfine can not calibrate the shell startup time much more precise than this limit. You can try to use the `-N`/`--shell=none` option to disable the shell completely.

Note: that run produced 24 results (26 lines, 1987 bytes) for this code, while ripgrep produces 938 results (63908 bytes).

2 Likes

Hey, thanks for the information. Will definitely have a lookt at your code.

I use hyperfine too for benchmarking. And I’m really happy that my code is only double the time of ripgrep despite missing parallelism etc. (At least for those small dir subtree)

The printing is only for debugging purposes. Later, I want to use the code with my tui file manager. But the tui lib has a different much more complex printing logic.

Edit: regarding your benchmarks, be aware that ripgrep does by default ignore some dirs (e.g. .git and .gitignore). This made a big difference for me when searching through dirs that might contain git repos.

Ah, that makes sense.

Ah, I’d only heard of ripgrep from colleagues, not used it myself. There weren’t any .gitignore files though. It was nevertheless a skill issue:

First was the -r flag, because I’m used to regular grep. It’s not the same here.

Second, I hadn’t specified the -c flag, so it was printing all matches in a file, not just the first. Once I fixed that, there were only 22 lines of output:

hyperfine --warmup=3 'rg  -c "main" ~/Downloads'
Benchmark 1: rg  "main" ~/Downloads
  Time (mean ± σ):       5.2 ms ±   0.6 ms    [User: 6.7 ms, System: 5.9 ms]
  Range (min … max):     3.7 ms …   7.6 ms    528 runs
 
  Warning: Command took less than 5 ms to complete. Note that the results might be inaccurate because hyperfine can not calibrate the shell startup time much more precise than this limit. You can try to use the `-N`/`--shell=none` option to disable the shell completely.

And adding --no-ignore didn’t change the results.

Not that all of that really matters, since I was just trying to demonstrate hyperfine and you’re already way ahead of me there. The performance you’ve got there is perfectly fine, as you said. I don’t judge until you start getting 200x worse performance - that’s what we have javascript for :slight_smile: .


What’s more interesting is the difference in the output, now that I’m actually using ripgrep correctly:

Your code gave 24 results, whereas rg -c "main" ~/Downloads gave 22. There are 4 differences:

  • 3 additional files from your code - all of them .pdf files.
    • I think it’s because they have NULL bytes in them after 1024 bytes in, meaning ripgrep detects them, but your code does not. There are no other .pdf files in the ripgrep output.
  • 1 Additional file in the ripgrep output.
    • I don’t have an obvious explanation for this one.
    • GNU grep also detects it fine

This is the file in question - with a .txt extension so I can upload it.

syncthing-start.desktop.txt (299 Bytes)

Looks like it should be picked up - I’m curious why it isn’t.

1 Like

Haha, yeah, or Python… (Just kidding).

I think that’s because of my too simple approach checking for text files. It just takes the first 1024 bytes and checks if there is a single byte == 0. If that’s the case, its interpreted as binary, if not, as text file. The PDFs might have slipped through. Maybe I need to tighten the binary check.

That’s weird. Maybe there is a hidden 0 byte or similar that its interpreted as binary and skipped?

I suppose it depends on whether you want to get results in PDF’s as well; ripgrep, for example, describes a method to do that using pdftotext.

No, there’s no null bytes - I checked the Hex.


I was able to narrow down the issue: if I remove any of these three characters, it finds the file:

  VV V
Comment=Starts the main syncthing process in the background.

I made some short test files: these ones don’t get found:
mm n main
m nn main
mm nmain
mmnnmain
mnmnmain
mnnnmain
mnnnnmain
But these ones do get found:
mn main
mm main
n mm main
mmn main
mm main n
nn main mm
mnnmnnmain

Strange bug.

EDIT: I see the pattern now: when there is a preceding m..n (dot can be any character), then it will not see the main after it.

That makes sense, given how the algorithm works. Clearly it’s finding all the occurrences, but after checking the first one it gives up instead of trying the rest.

1 Like

Yes, that should be the reason. But there are ways to get this done, for sure, as ripgrep is capable of doing it. It very likely due to the fact that my test approach is very (too) simplistic

Yep, it wasn’t too difficult to find and fix: https://codeberg.org/vincent-dalstra/lukeflo-grep/commit/e74092eab9d4e0da2a8d9e109da00c00c3ac04f4

Fun little algorithm, and the paper was an interesting read. They did gloss over the ‘multiple results’ though.

Since the mask is non-zero, it means there are possible substring occurrences. As we see, there is only one non-zero element at index 2, thus only one substring comparison must be performed.

1 Like

Yes, nice. Thank you! I’ve already read through this part of bit shifting, but decided to ignore it for the first sketch. Now, it wasn’t on my mind anymore. Great you found it :+1:

Regarding this: I enhanced the matching for typical binary bytes and increased the buffer size to 4096 bytes for testing:

fn isText(buf: []const u8) bool {
    if (buf.len == 0) return false;

    for (buf) |b| {
        if (b == 0) return false;
        if (b < 0x09) return false;
        if (b >= 0x0e and b <= 0x1f) return false;
    }

    return true;
}

If I run the snippet with this function on my literature-pdf dir (which contains more than 1200 PDF files), I get much less false positives than with the original test and buffer size. However, some still slip through and are marked as text files…

Have to inverstigate a better checking function, while keeping buffer size and conditional checks as low as possible. If we check every byte of every file for deciding “plain text yes/no” that would be way too much overhead…

Edit: I checked ripgreps code how they determine this. They seem to use a fixed file extension table in the first place: ripgrep/crates/ignore/src/default_types.rs at master · BurntSushi/ripgrep · GitHub
If the matching should be enhanced only relying on bytes seems to always produce some false positives. It should be combined with file extensions and, if neccessary, mime-type database.

However, I use the above test for lui (only difference: interpreting empty files as plain text to open them correctly) and haven’t encountered any false positive/negative during my daily usage.

2nd Edit: I narrowed it down a little bit. This problem seems very much only related to PDF files. At least, I tested many other file formats with a hex viewer and all would be catched by the test despite some special PDFs. The PDF files that slip through often have a more or less large part XML-like definitions at the beginning. E.g. like this:

%PDF-1.6
%âãÏÓ
908 0 obj
<</Length 7141/Subtype/XML/Type/Metadata>>
stream
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 4.2.1-c041 52.342996, 2008/05/07-20:48:00        ">
   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
      <rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" rdf:about="">
         <xmp:CreateDate>2019-09-13T13:13:18+02:00</xmp:CreateDate>
         <xmp:MetadataDate>2021-06-07T22:45:13+02:00</xmp:MetadataDate>
         <xmp:ModifyDate>2019-10-17T12:40:38+02:00</xmp:ModifyDate>
         <xmp:CreatorTool>PubEngine</xmp:CreatorTool>
      </rdf:Description>
      <rdf:Description xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#" rdf:about="">
         <xmpMM:InstanceID>uuid:57dd5e51-72c1-4e27-946b-4f81043369d7</xmpMM:InstanceID>
         <xmpMM:OriginalDocumentID>xmp.did:F77F11740720681183D19C1C6565A978</xmpMM:OriginalDocumentID>
         <xmpMM:DocumentID>xmp.id:ece473a2-5509-4192-afc1-75d5d051cf00</xmpMM:DocumentID>
         <xmpMM:RenditionClass>proof:pdf</xmpMM:RenditionClass>
         <xmpMM:DerivedFrom rdf:parseType="Resource">
...
...
...

In this example file the first 0 byte only occurs much later than byte index 4096.

Thus, the simplest solution would be to filter by the isText() function AND file ending pdf:

Using the following snippet inside the matchFileContent() function produces no false positives anymore on my 1200+ PDFs dir:

// more code
    const file_ext = std.fs.path.extension(file_name);

    // check for text file
    const i = try file.readPositionalAll(io, &check_txt_buf, 0);
    if (std.ascii.eqlIgnoreCase(file_ext, ".pdf") or !isText(check_txt_buf[0..i])) {
        std.log.debug("No text file: {s}", .{file_path});
        return;
    }
// more code