001 Notes

ClamAV SIMD Fast Paths For Parser-Heavy Scanning

0. Abstract

This note keeps the code-heavy version of the ClamAV SIMD work.

The target was not the whole scanner at once. The target was the parser-heavy inner loops that keep showing up in file scanning:

  • string search inside PDFs and containers;
  • whitespace skipping in structured formats;
  • fixed-size hash equality checks;
  • CRC32 over archive data;
  • base64 decode for mail-like payloads.

The measured prototype results were:

OptimizationActual measured result
String search11x speedup
Whitespace skip6.2x speedup
Base64 decode6.15 GB/s, roughly 12x over the scalar reference
Hash equalityabout 2x
Hardware CRC32about 4x

Important boundary:

  • these are component-level and microbenchmark results;
  • they are not a full scanner wall-clock benchmark;
  • scanner-level impact later in this note is therefore estimated, not directly measured.

The strongest measured win came from marker search.

Original Scalar Shape

The scalar implementation searched one position at a time and used a quick-check on the second character:

const char *cli_memstr(const char *haystack, size_t hs, const char *needle, size_t ns)
{
    size_t i, s1, s2;

    if (!hs || !ns || hs < ns)
        return NULL;

    if (needle == haystack)
        return haystack;

    if (ns == 1)
        return memchr(haystack, needle[0], hs);

    if (needle[0] == needle[1]) {
        s1 = 2;
        s2 = 1;
    } else {
        s1 = 1;
        s2 = 2;
    }

    for (i = 0; i <= hs - ns;) {
        if (needle[1] != haystack[i + 1]) {
            i += s1;
        } else {
            if ((needle[0] == haystack[i]) &&
                !memcmp(needle + 2, haystack + i + 2, ns - 2))
                return &haystack[i];
            i += s2;
        }
    }

    return NULL;
}

Why this is costly:

  • it examines only 1 to 2 bytes per loop step;
  • it branches at almost every candidate position;
  • it leaves vector hardware idle.

SIMD Replacement

The vector version filters candidate positions by checking the first and last needle bytes across 16 starts at once:

const char *simd_memstr_neon(const char *haystack, size_t hs,
                             const char *needle, size_t ns)
{
    if (ns == 0) return haystack;
    if (hs < ns) return NULL;
    if (ns == 1) return memchr(haystack, needle[0], hs);

    const char first = needle[0];
    const char last  = needle[ns - 1];

    uint8x16_t first_vec = vdupq_n_u8((uint8_t)first);
    uint8x16_t last_vec  = vdupq_n_u8((uint8_t)last);

    size_t i = 0;
    size_t end = hs - ns + 1;

    while (i + 16 <= end) {
        uint8x16_t block_first = vld1q_u8((const uint8_t *)(haystack + i));
        uint8x16_t block_last  = vld1q_u8((const uint8_t *)(haystack + i + ns - 1));

        uint8x16_t eq_first = vceqq_u8(block_first, first_vec);
        uint8x16_t eq_last  = vceqq_u8(block_last, last_vec);
        uint8x16_t candidates = vandq_u8(eq_first, eq_last);

        uint64_t mask_lo = vgetq_lane_u64(vreinterpretq_u64_u8(candidates), 0);
        uint64_t mask_hi = vgetq_lane_u64(vreinterpretq_u64_u8(candidates), 1);

        while (mask_lo) {
            int pos = __builtin_ctzll(mask_lo) / 8;
            if (memcmp(haystack + i + pos + 1, needle + 1, ns - 2) == 0) {
                return haystack + i + pos;
            }
            mask_lo &= mask_lo - 1;
        }

        while (mask_hi) {
            int pos = 8 + __builtin_ctzll(mask_hi) / 8;
            if (memcmp(haystack + i + pos + 1, needle + 1, ns - 2) == 0) {
                return haystack + i + pos;
            }
            mask_hi &= mask_hi - 1;
        }

        i += 16;
    }

    for (; i < end; ++i) {
        if (haystack[i] == first && haystack[i + ns - 1] == last) {
            if (memcmp(haystack + i + 1, needle + 1, ns - 2) == 0) {
                return haystack + i;
            }
        }
    }

    return NULL;
}

Why The Vector Shape Wins

old:
position -> compare -> branch -> maybe compare -> branch

new:
16 positions -> compare first bytes in parallel
             -> compare last bytes in parallel
             -> AND masks
             -> scalar verify only surviving candidates

For short markers like stream, the first-plus-last-byte filter kills almost all false positives before the expensive work starts.

Measured Result

NeedleScalar GB/sSIMD GB/sSpeedup
stream3.3336.6111.0x
endstream3.3136.5111.0x
obj3.4838.4511.0x
endobj3.3137.0211.2x
/Length3.2936.9011.2x

2. SIMD Whitespace Skip

Whitespace skipping is boring code that ends up on hot paths.

Original Scalar Shape

static const char *findNextNonWS(const char *q, const char *end)
{
    while (q < end &&
           (*q == 0 || *q == 9 || *q == 0xa ||
            *q == 0xc || *q == 0xd || *q == 0x20))
        q++;

    return q;
}

The cost comes from asking six questions for every byte.

SIMD Replacement

const char *simd_skip_ws(const char *ptr, const char *end)
{
    if (ptr >= end) return ptr;

    while (ptr + 16 <= end) {
        uint8x16_t data = vld1q_u8((const uint8_t *)ptr);

        uint8x16_t is_null  = vceqq_u8(data, vdupq_n_u8(0x00));
        uint8x16_t is_tab   = vceqq_u8(data, vdupq_n_u8(0x09));
        uint8x16_t is_lf    = vceqq_u8(data, vdupq_n_u8(0x0A));
        uint8x16_t is_ff    = vceqq_u8(data, vdupq_n_u8(0x0C));
        uint8x16_t is_cr    = vceqq_u8(data, vdupq_n_u8(0x0D));
        uint8x16_t is_space = vceqq_u8(data, vdupq_n_u8(0x20));

        uint8x16_t is_ws = vorrq_u8(is_null, is_tab);
        is_ws = vorrq_u8(is_ws, is_lf);
        is_ws = vorrq_u8(is_ws, is_ff);
        is_ws = vorrq_u8(is_ws, is_cr);
        is_ws = vorrq_u8(is_ws, is_space);

        uint8_t min_val = vminvq_u8(is_ws);
        if (min_val == 0) {
            uint8x16_t not_ws = vmvnq_u8(is_ws);
            uint64_t mask_lo = vgetq_lane_u64(vreinterpretq_u64_u8(not_ws), 0);
            uint64_t mask_hi = vgetq_lane_u64(vreinterpretq_u64_u8(not_ws), 1);

            if (mask_lo) {
                return ptr + __builtin_ctzll(mask_lo) / 8;
            }
            return ptr + 8 + __builtin_ctzll(mask_hi) / 8;
        }

        ptr += 16;
    }

    while (ptr < end &&
           (*ptr == 0 || *ptr == 9 || *ptr == 0xa ||
            *ptr == 0xc || *ptr == 0xd || *ptr == 0x20))
        ptr++;

    return ptr;
}

Why The Vector Shape Wins

scalar:
1 byte -> 6 serial comparisons

SIMD:
16 bytes -> 6 vector comparisons -> OR masks -> skip whole block

Best case:

scalar: skip 1 byte
SIMD:   skip 16 bytes

Measured Result

BufferScalar GB/sSIMD GB/sSpeedup
256 bytes3.3620.986.2x
1 KB3.2331.599.8x

Public claim boundary:

  • 6.2x is the conservative quoted achieved result;
  • the 1 KB row shows that cleaner long runs of whitespace let the vector path scale harder.

3. SIMD Hash Equality

Fixed-size hashes are a small but regular workload.

Scalar Shape

if (memcmp(hash1, hash2, 16) == 0) {
    /* match */
}

SIMD Replacement

bool simd_hash_equal(const uint8_t *a, const uint8_t *b, hash_type_t type)
{
    size_t size;
    switch (type) {
        case HASH_MD5:    size = 16; break;
        case HASH_SHA1:   size = 20; break;
        case HASH_SHA256: size = 32; break;
        default: return memcmp(a, b, 16) == 0;
    }

    if (size == 16) {
        uint8x16_t va = vld1q_u8(a);
        uint8x16_t vb = vld1q_u8(b);
        uint8x16_t cmp = vceqq_u8(va, vb);
        return vminvq_u8(cmp) == 0xFF;
    } else if (size == 32) {
        uint8x16_t va0 = vld1q_u8(a);
        uint8x16_t va1 = vld1q_u8(a + 16);
        uint8x16_t vb0 = vld1q_u8(b);
        uint8x16_t vb1 = vld1q_u8(b + 16);

        uint8x16_t cmp0 = vceqq_u8(va0, vb0);
        uint8x16_t cmp1 = vceqq_u8(va1, vb1);
        uint8x16_t cmp = vandq_u8(cmp0, cmp1);
        return vminvq_u8(cmp) == 0xFF;
    }

    return memcmp(a, b, size) == 0;
}

Why The Gain Is Smaller

  • the scalar baseline is already compact;
  • the compared payload is tiny;
  • many libc memcmp implementations are already well optimized.

Still, the vector path makes the work explicit: a few wide loads, a compare, and one reduction.

Measured Result

OperationResult
MD5 equality908 M ops/sec
SHA-256 equality735 M ops/sec
Relative speedupabout 2x

4. Hardware CRC32

CRC32 is not classic SIMD lane work. The win comes from ISA support.

Scalar Shape

#include <zlib.h>

uint32_t checksum = crc32(0, data, length);

Hardware-Assisted Shape

#if defined(__aarch64__) && defined(__ARM_FEATURE_CRC32)
#include <arm_acle.h>

uint32_t simd_crc32c(uint32_t crc, const uint8_t *data, size_t len)
{
    crc = ~crc;

    while (len >= 8) {
        crc = __crc32cd(crc, *(const uint64_t *)data);
        data += 8;
        len -= 8;
    }

    while (len >= 4) {
        crc = __crc32cw(crc, *(const uint32_t *)data);
        data += 4;
        len -= 4;
    }

    while (len > 0) {
        crc = __crc32cb(crc, *data);
        data++;
        len--;
    }

    return ~crc;
}
#endif

Throughput View

MethodThroughput
Software CRC32about 500 MB/s
Hardware CRC32about 20 GB/s

Measured Result

WorkloadResult
CRC32about 4x

The large throughput difference and the smaller end result are not contradictory. The whole component still pays surrounding work, so peak primitive throughput does not become full-program speedup one-for-one.

5. SIMD Base64 Decode

Base64 is another place where scalar code often survives longer than it should.

Scalar Shape

for (i = 0; i < len; i += 4) {
    uint8_t a = decode_table[input[i]];
    uint8_t b = decode_table[input[i + 1]];
    uint8_t c = decode_table[input[i + 2]];
    uint8_t d = decode_table[input[i + 3]];

    output[j++] = (a << 2) | (b >> 4);
    output[j++] = (b << 4) | (c >> 2);
    output[j++] = (c << 6) | d;
}

SIMD Replacement

ssize_t simd_base64_decode(const char *input, size_t len,
                           uint8_t *output, size_t out_capacity)
{
    while (in + 16 <= input + len) {
        uint8x16_t data = vld1q_u8((const uint8_t *)in);

        uint8x16_t decoded = base64_lookup_neon(data);
        uint8x16_t packed  = base64_pack_neon(decoded);

        vst1q_u8(out, packed);

        in  += 16;
        out += 12;
    }

    /* scalar tail */
}

Why The Vector Shape Wins

scalar:
4 chars -> 3 output bytes

SIMD:
16 chars -> parallel decode -> parallel repack -> 12 output bytes

Measured Result

Data sizeThroughput
64 bytes2.73 GB/s
1 KB3.38 GB/s
64 KB6.15 GB/s

Summary claim:

WorkloadResult
Base64 decodeabout 6.15 GB/s
Relative speeduproughly 12x over the scalar reference

One important warning survived the prototype:

  • base64 with embedded whitespace was much worse than clean-input base64;
  • that means surrounding whitespace stripping remained a real bottleneck.

6. Benchmark Summary

Expectation Versus Result

OptimizationExpected speedupActual speedupStatus
String search8x to 16x11xmet
Whitespace skip4x to 8x6.2xmet
Base64 decode4x to 8xabout 12xexceeded
Hash equality2x to 4xabout 2xmet
Hardware CRC324x to 10x4xmet

Directly Measured

  • string-search throughput;
  • whitespace-skip throughput;
  • base64 throughput;
  • hash-comparison throughput;
  • the summary table above.

Estimated From The Hotspot Mix

File typeEstimated improvement
PDF50% to 70% faster
ZIP30% to 50% faster
MBOX / email40% to 60% faster
HTML30% to 40% faster
PE20% to 30% faster

Those are engineering estimates derived from component wins plus expected hotspot composition. They are not end-to-end scanner benchmarks.

7. Limitations

Two caveats mattered in the prototype state:

  1. one cache-oriented hash-database layout still had correctness issues and was not ready to be recommended as a public result;
  2. base64 with embedded whitespace still needed a better SIMD-friendly surrounding path.

So the result is not “the scanner is solved.” The result is that several small parser loops had large, measurable headroom.

8. Bottom Line

The useful optimization lesson here is simple:

Do not only optimize the signature engine.
Optimize the parser-shaped byte work around it.

The highest-impact wins came from loops that looked small enough to ignore:

  • substring search;
  • whitespace skipping;
  • short hash equality;
  • CRC calculation;
  • base64 decode.

The code-heavy takeaway from this note is that those loops become much cheaper when they stop acting on one byte at a time.

The measured prototype result set remains:

  • 11x on string search;
  • 6.2x on whitespace skip;
  • roughly 12x on clean base64 decode;
  • about 4x on CRC32;
  • about 2x on short hash equality.

That is enough to justify deeper end-to-end ClamAV benchmarking, and not enough to pretend that scanner-wide speedups are already proven.

Comments