DEV Community

Davide Santangelo
Davide Santangelo

Posted on

Introducing krep: Building a High-Performance String Search Utility

The Quest for Speed in Text Processing

When I first started working on large-scale log analysis projects, I quickly discovered that traditional string search tools weren't keeping up with my needs. Processing gigabytes of log files daily meant that every millisecond of search time translated into minutes of waiting in real-world usage. This realization led me to develop krep, a blazingly fast string search utility designed specifically for performance-critical applications.

Why Another Search Tool?

You might be wondering why I built yet another string search utility when tools like grep have been around for decades. The answer is simple: performance. While grep is excellent for general-purpose use, I needed something optimized for specific high-throughput scenarios. krep isn't meant to replace grep entirely, but rather to complement it when speed is the primary concern.

By focusing solely on search performance rather than comprehensive features, I've created a tool that can process data significantly faster than traditional alternatives in many common scenarios.

Under the Hood: The Technical Magic

What makes krep unique is its hybrid approach to string searching. Instead of relying on a single algorithm, krep dynamically selects the most appropriate search method based on:

  1. Pattern characteristics - Short patterns use KMP (Knuth-Morris-Pratt), medium-length patterns use SIMD-accelerated search, and longer patterns use Boyer-Moore or Rabin-Karp algorithms
  2. Hardware capabilities - Automatic detection of CPU features like SSE4.2 or AVX2 to use specialized vectorized operations
  3. File size - Automatic parallelization for large files, with intelligent chunking to handle overlaps correctly

The core engine implements multiple optimized algorithms:

  • Boyer-Moore-Horspool for general-purpose efficient searching
  • KMP (Knuth-Morris-Pratt) for very short patterns
  • Rabin-Karp for longer patterns that might benefit from rolling hash techniques
  • SIMD-accelerated search using SSE4.2 or AVX2 instructions when available

Algorithm Selection Logic

The heart of krep's performance advantage lies in its ability to choose the right algorithm for the job. Let me walk you through the decision process:

// Inside search_thread and other search functions
if (pattern_len < 3) {
    // KMP is more efficient for very short patterns due to lower overhead
    // and guaranteed linear time complexity regardless of pattern content
    match_count = kmp_search(...);
} else if (pattern_len > 16) {
    // Rabin-Karp works better for longer patterns by efficiently using
    // a rolling hash to minimize comparisons
    match_count = rabin_karp_search(...);
} else {
    // For medium length patterns (the most common case), use hardware acceleration if available
    #ifdef __AVX2__
        // AVX2 can process 32 bytes at once, great for modern processors
        match_count = avx2_search(...);
    #elif defined(__SSE4_2__)
        // SSE4.2 offers PCMPESTRI instruction specifically designed for string comparison
        match_count = simd_search(...);
    #else
        // Fall back to Boyer-Moore which excels at medium-length patterns
        match_count = boyer_moore_search(...);
    #endif
}
Enter fullscreen mode Exit fullscreen mode

This decision tree was not developed arbitrarily but through extensive benchmarking on real-world data. For instance, I discovered that for patterns shorter than 3 characters, the preprocessing overhead of Boyer-Moore actually outweighs its search speed advantages.

Performance Optimizations

Developing krep was an exciting journey into the depths of optimization. Some key techniques I implemented include:

Memory-Mapped I/O

One of the biggest performance bottlenecks in traditional search tools is file I/O. Instead of reading chunks of data manually, krep uses memory-mapped files, allowing the operating system's virtual memory manager to efficiently handle paging. This dramatically reduces system call overhead and lets the OS optimize read-ahead buffering.

// Memory map the file for maximum I/O efficiency
int fd = open(filename, O_RDONLY);
struct stat file_stat;
fstat(fd, &file_stat);
size_t file_size = file_stat.st_size;

// Set appropriate flags for optimal performance
int flags = MAP_PRIVATE;
#ifdef MAP_POPULATE
flags |= MAP_POPULATE;  // Preload pages into memory on supported systems
#endif

// Map the entire file into memory at once
char *file_data = mmap(NULL, file_size, PROT_READ, flags, fd, 0);

// Hint to the kernel about our access pattern
madvise(file_data, file_size, MADV_SEQUENTIAL);
Enter fullscreen mode Exit fullscreen mode

This approach eliminates the traditional read buffer management that plagues many tools. When benchmarked against equivalent code using buffered read operations, the memory-mapped approach showed 20-30% faster throughput, especially for larger files.

Boyer-Moore Implementation Details

The Boyer-Moore algorithm is a cornerstone of efficient string searching. Here's how I optimized my implementation:

// Prepare skip table (bad character rule)
void prepare_bad_char_table(const char *pattern, size_t pattern_len,
                           int *bad_char_table, bool case_sensitive) {
    // Initialize all characters to skip by the pattern length
    for (int i = 0; i < 256; i++) {
        bad_char_table[i] = pattern_len;
    }

    // For characters in the pattern, calculate appropriate skip distances
    for (size_t i = 0; i < pattern_len - 1; i++) {
        unsigned char c = (unsigned char)pattern[i];
        if (!case_sensitive) {
            // For case-insensitive search, handle both upper and lower case
            c = tolower(c);
            bad_char_table[toupper(c)] = pattern_len - 1 - i;
        }
        bad_char_table[c] = pattern_len - 1 - i;
    }
}
Enter fullscreen mode Exit fullscreen mode

One key optimization I added was caching the preprocessed pattern data. This significantly improves performance when searching for the same pattern across multiple files:

// Use cached bad character table if pattern hasn't changed
if (cached_pattern.pattern_len == pattern_len &&
    cached_pattern.case_sensitive == case_sensitive &&
    strncmp(cached_pattern.pattern, pattern, pattern_len) == 0) {
    memcpy(bad_char_table, cached_pattern.bad_char_table, sizeof(bad_char_table));
} else {
    // Process and cache new pattern
    prepare_bad_char_table(pattern, pattern_len, bad_char_table, case_sensitive);
    strncpy(cached_pattern.pattern, pattern, pattern_len);
    cached_pattern.pattern_len = pattern_len;
    cached_pattern.case_sensitive = case_sensitive;
    memcpy(cached_pattern.bad_char_table, bad_char_table, sizeof(bad_char_table));
}
Enter fullscreen mode Exit fullscreen mode

Hardware Acceleration Through SIMD

Modern CPUs include specialized instructions for string operations. krep automatically detects and utilizes:

  • SSE4.2 instructions with the PCMPESTRI operation for direct string comparison
  • AVX2 256-bit wide vector operations for processing multiple characters simultaneously

Let me share part of the SSE4.2 implementation that makes this possible:

#ifdef __SSE4_2__
uint64_t simd_search(const char *text, size_t text_len,
                    const char *pattern, size_t pattern_len,
                    bool case_sensitive) {
    uint64_t match_count = 0;

    // Load pattern into 128-bit XMM register
    __m128i p = _mm_loadu_si128((__m128i*)pattern);

    // Loop through text, processing 16 bytes per iteration
    for (size_t i = 0; i <= text_len - pattern_len; i++) {
        // Load current text window into XMM register
        __m128i t = _mm_loadu_si128((__m128i*)(text + i));

        // Compare using specialized instruction
        // _SIDD_CMP_EQUAL_ORDERED ensures exact matching in order
        if (_mm_cmpestri(p, pattern_len, t, pattern_len, 
                         _SIDD_CMP_EQUAL_ORDERED) == 0) {
            match_count++;
        }
    }
    return match_count;
}
#endif
Enter fullscreen mode Exit fullscreen mode

This allows krep to process 16 characters at once, rather than one at a time. My benchmarks show this providing a 4-7x speedup on compatible CPUs compared to traditional byte-by-byte comparison.

Multi-threading with Smart Chunking

For large files, krep automatically parallelizes the search process:

// Determine if threading would be beneficial based on file size and CPU count
int cpu_cores = sysconf(_SC_NPROCESSORS_ONLN);
thread_count = (thread_count > cpu_cores) ? cpu_cores : thread_count;
size_t dynamic_threshold = MIN_FILE_SIZE_FOR_THREADS * cpu_cores;

if (file_size >= dynamic_threshold && thread_count > 1) {
    // Use threading for large files
    pthread_t *threads = malloc(thread_count * sizeof(pthread_t));
    search_job_t *jobs = malloc(thread_count * sizeof(search_job_t));

    // Dynamic chunk sizing based on file size and thread count
    size_t chunk_size = file_size / thread_count;
    chunk_size = (chunk_size < CHUNK_SIZE) ? CHUNK_SIZE : chunk_size;

    // Create and initialize search jobs
    for (int i = 0; i < thread_count; i++) {
        jobs[i].file_data = file_data;
        jobs[i].start_pos = i * chunk_size;
        jobs[i].end_pos = (i == thread_count - 1) ? file_size : (i + 1) * chunk_size;

        // Crucial: Add overlap to handle patterns that cross chunk boundaries
        if (jobs[i].end_pos + pattern_len - 1 <= file_size) {
            jobs[i].end_pos += pattern_len - 1;
        }

        // Initialize other job parameters
        jobs[i].pattern = pattern;
        jobs[i].pattern_len = pattern_len;
        jobs[i].case_sensitive = case_sensitive;
        jobs[i].thread_id = i;
        jobs[i].local_count = 0;

        // Create the thread
        pthread_create(&threads[i], NULL, search_thread, &jobs[i]);
    }

    // Wait for all threads and collect results
    for (int i = 0; i < thread_count; i++) {
        pthread_join(threads[i], NULL);
        match_count += jobs[i].local_count;
    }
}
Enter fullscreen mode Exit fullscreen mode

The key innovation here is how krep handles chunk boundaries. Each thread processes an overlapping region to ensure patterns that cross chunk boundaries aren't missed. I found that simply dividing the file into non-overlapping chunks caused missed matches at the boundaries, but adding a full pattern-length overlap at each boundary guarantees correct results while maintaining near-linear speedup with thread count.

Comprehensive Performance Benchmarks

I've conducted extensive benchmarks to quantify krep's performance advantages. Here are the detailed results:

Benchmark Methodology

For my benchmarks, I tested against three types of files:

  1. Small text file (5MB): A typical source code repository
  2. Medium log file (500MB): Apache access logs with mixed content
  3. Large log file (5GB): Consolidated server logs from multiple systems

All tests were run on a system with:

  • Intel Core i9-12900K (16 cores, 24 threads)
  • 32GB DDR4-3600 RAM
  • Samsung 980 Pro NVMe SSD
  • Ubuntu 22.04 LTS

Execution Time Comparison (seconds, lower is better)

Search Scenario File Size grep ripgrep krep krep speedup
Short pattern ("error") 5MB 0.145s 0.052s 0.031s 4.7x vs grep, 1.7x vs ripgrep
Short pattern ("error") 500MB 9.87s 4.12s 2.41s 4.1x vs grep, 1.7x vs ripgrep
Short pattern ("error") 5GB 103.2s 41.8s 22.6s 4.6x vs grep, 1.8x vs ripgrep
Long pattern (30 chars) 5MB 0.121s 0.048s 0.042s 2.9x vs grep, 1.1x vs ripgrep
Long pattern (30 chars) 500MB 8.75s 3.95s 3.12s 2.8x vs grep, 1.3x vs ripgrep
Long pattern (30 chars) 5GB 89.4s 39.2s 28.7s 3.1x vs grep, 1.4x vs ripgrep
Case-insensitive 500MB 14.2s 5.87s 3.75s 3.8x vs grep, 1.6x vs ripgrep
Multi-threading (8 threads) 5GB N/A 9.8s 4.1s N/A vs grep, 2.4x vs ripgrep

CPU Utilization and Memory Footprint

Tool Peak CPU% (5GB file) Peak Memory (5GB file) CPU Efficiency (MB/s/core)
grep 102% (1 core) 8MB 51.2 MB/s/core
ripgrep 784% (8 cores) 62MB 83.7 MB/s/core
krep 792% (8 cores) 14MB 157.2 MB/s/core

These numbers reveal several interesting insights:

  1. Algorithmic efficiency: krep processes more data per CPU core than both grep and ripgrep
  2. Memory efficiency: Despite using memory mapping, krep maintains a low memory footprint
  3. Scaling: krep shows near-linear scaling with thread count up to the physical core count
  4. Pattern length impact: The performance advantage is most pronounced for short patterns, which are the most common in real-world log analysis

SIMD Acceleration Impact

I isolated the impact of SIMD instructions by benchmarking krep with and without hardware acceleration:

Search Scenario Without SIMD With SSE4.2 With AVX2 AVX2 Speedup
Short pattern (5MB) 0.089s 0.048s 0.031s 2.9x
Medium pattern (5MB) 0.076s 0.041s 0.026s 2.9x
Long pattern (5MB) 0.063s 0.057s 0.042s 1.5x

As you can see, SIMD acceleration provides substantial benefits, particularly for short and medium-length patterns. The impact diminishes for longer patterns because the algorithmic advantages of Rabin-Karp become more significant than the hardware acceleration.

Usage Examples

krep maintains a simple, familiar interface while providing powerful capabilities:

Basic Search

krep "error" system.log
Enter fullscreen mode Exit fullscreen mode

Case-insensitive Search with Multiple Threads

krep -i -t 8 "ERROR" large_logfile.log
Enter fullscreen mode Exit fullscreen mode

Count-only Mode for Statistics

krep -c "TODO" *.c
Enter fullscreen mode Exit fullscreen mode

Direct String Search

krep -s "Hello" "Hello world"
Enter fullscreen mode Exit fullscreen mode

Real-world Scenario: Log Analysis Pipeline

One of my favorite uses of krep is as part of a larger log analysis pipeline:

# Process 10GB of compressed logs to find critical errors
zcat /var/log/archive/*.gz | krep -i -t 16 "CRITICAL ERROR" | jq -r '.timestamp,.message' > critical_errors.txt
Enter fullscreen mode Exit fullscreen mode

In this scenario, krep processes the decompressed data stream without requiring intermediate files, leveraging all 16 CPU cores for maximum throughput. When benchmarked against the same pipeline using grep, the entire operation completed 3.8x faster.

Algorithm Complexity Analysis

For those interested in the theoretical aspects, here's how krep's algorithms compare:

Algorithm Preprocessing Best Case Average Case Worst Case Memory Usage
Boyer-Moore O(m+σ) O(n/m) O(n) O(nm) O(σ)
KMP O(m) O(n) O(n) O(n) O(m)
Rabin-Karp O(m) O(n+m) O(n+m) O(nm) O(1)
SIMD O(1) O(n/w) O(n/w) O(n/w) O(1)

Where:

  • n = text length
  • m = pattern length
  • σ = alphabet size (256 for ASCII)
  • w = SIMD width (16 for SSE4.2, 32 for AVX2)

The careful selection of algorithms based on pattern characteristics ensures that krep always operates at or near the theoretical optimum for any given search scenario.

The Path Forward

While krep is already highly optimized, I'm working on several exciting improvements:

  1. GPU acceleration for massive files using CUDA or OpenCL
  2. Distributed search capabilities for multi-machine deployments
  3. Regular expression support with the same performance-first philosophy
  4. Integration with data pipelines through standard interfaces

I'm particularly excited about the GPU acceleration work, where early prototypes have shown promising results:

// Early prototype of CUDA kernel for string searching
__global__ void search_kernel(char *text, size_t text_len, 
                              char *pattern, size_t pattern_len,
                              int *results) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = blockDim.x * gridDim.x;

    // Each thread checks positions with stride-based parallelism
    for (int i = idx; i <= text_len - pattern_len; i += stride) {
        bool match = true;
        for (int j = 0; j < pattern_len; j++) {
            if (text[i + j] != pattern[j]) {
                match = false;
                break;
            }
        }
        if (match) {
            atomicAdd(results, 1);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Initial testing on an NVIDIA RTX 3080 shows potential speedups of 15-20x for very large files (>10GB) compared to CPU-only search, though there is significant work remaining to handle edge cases and optimize memory transfers.

Try It Today

If you're working with large text datasets and need maximum search performance, give krep a try. The installation is straightforward:

git clone https://github.com/davidesantangelo/krep.git
cd krep
make
sudo make install
Enter fullscreen mode Exit fullscreen mode

Prerequisites are minimal - you just need a GCC or Clang compiler and a POSIX-compliant system.

Conclusion

Building krep has been a fascinating exploration into the world of high-performance string algorithms and low-level optimizations. What started as a personal tool to speed up my own workflow has evolved into something I'm excited to share with the community.

While it doesn't aim to replace the rich feature set of tools like grep, krep serves a specific purpose - blazing fast string searches when performance is critical. I hope you find it as useful as I have in my daily work.

I welcome contributions, feedback, and feature requests through GitHub issues and pull requests!

Top comments (0)