↓ Skip to main content

Cracking irssi fish with SHA256-NI

·3038 words·15 mins
Max Severance
Author
Max Severance
Architecting fabric convergence at hyperscale.
Table of Contents

Debugging SHA256-NI: A Deep Dive into Endianness and Intel Hardware Acceleration
#

Introduction
#

This blog post documents an intense debugging session to fix a SHA256-NI (Intel SHA Extensions) implementation in a FiSH encryption module for the Irssi IRC client. The goal was to create a high-performance password cracker for the FiSH encryption protocol used in Irssi, which requires 71,624 SHA256 iterations per password attempt. After discovering that OpenSSL was way too slow, we dove deep into Intel’s hardware-accelerated SHA256 intrinsics—only to hit a subtle endianness bug that took minutes to track down.

TLDR
#

  • The Problem: SHA256-NI implementation produced completely wrong hashes despite following reference code exactly
  • Root Cause: memcpy(out, state, 32) copied uint32_t words in little-endian byte order, but SHA256 output requires big-endian byte order
  • The Fix: Explicit byte-swapping when writing output: out[i*4+0] = (w >> 24) & 0xff; etc.
  • Result: Working cracker that found 8-character password “kalamaja” in 29 seconds at ~4,100 candidates/sec across 12 E-cores
  • Performance: ~294 million SHA256 hashes/sec aggregate; E-cores are 105x faster than P-cores for SHA256-NI on the i7-14700K
  • LTO matters: Link-time optimization gives a 100x speedup by inlining the SHA256 compression into the iteration loop

Background: FiSH Encryption and the Cracking Challenge
#

The FiSH module for Irssi implements a custom key derivation function for Blowfish encryption. Understanding this algorithm is crucial for building an efficient cracker.

The FiSH Key Derivation Algorithm
#

The FiSH protocol uses a two-stage key derivation:

  1. key_from_password(): SHA256(password) → iterate 40,872 times
  2. key_hash(): Take result → iterate another 30,752 times
  3. Total: 40,872 + 30,752 = 71,624 SHA256 hashes per password attempt

This is intentionally slow to prevent brute-force attacks, but with SHA256-NI hardware acceleration, we can still achieve meaningful cracking speeds.

Test Vector Verification
#

Before implementing the cracker, we established a known test vector:

  • Password: foobar
  • Expected Hash: e0Q9ecuH1nM35Oq90eIMK8edeqJErtlug7VC5cmUjKs=

This test vector was generated using Python:

import hashlib
import base64

h = hashlib.sha256(b'foobar').digest()
for _ in range(40872):
    h = hashlib.sha256(h).digest()
for _ in range(30752):
    h = hashlib.sha256(h).digest()
print(base64.b64encode(h).decode())
# Output: e0Q9ecuH1nM35Oq90eIMK8edeqJErtlug7VC5cmUjKs=

Setting Up the Build System
#

We created a modular crypto implementation in fish_crypto.c and fish_crypto.h with a CMake build system.

The Header File (fish_crypto.h)
#

#ifndef FISH_CRYPTO_H
#define FISH_CRYPTO_H

#include <stdint.h>
#include <stddef.h>

void sha256_hash(const uint8_t* data, size_t len, uint8_t* out);
void key_from_password(const char* password, char* key);
void key_hash(const char* key, char* a_hash);
void compute_fish_hash(const char* password, char* out_b64);
void compute_fish_hash_raw(const char* password, uint8_t* out);
int base64_decode(const char* in, uint8_t* out);

#endif

The CMakeLists.txt
#

INCLUDE_DIRECTORIES(${CMAKE_SOURCE_DIR}/src)

ADD_EXECUTABLE(test_hash test_hash.c ${CMAKE_SOURCE_DIR}/src/fish_crypto.c)
TARGET_COMPILE_OPTIONS(test_hash PRIVATE -msha -march=native -flto)
TARGET_LINK_OPTIONS(test_hash PRIVATE -flto -msha -march=native)
TARGET_LINK_LIBRARIES(test_hash crypto)

ADD_EXECUTABLE(crack crack.c ${CMAKE_SOURCE_DIR}/src/fish_crypto.c)
TARGET_COMPILE_OPTIONS(crack PRIVATE -msha -march=native -flto -O3)
TARGET_LINK_OPTIONS(crack PRIVATE -flto -msha -march=native)
TARGET_LINK_LIBRARIES(crack crypto pthread)

The critical flags are:

  • -msha: Enable Intel SHA extensions
  • -march=native: Use CPU-specific optimizations
  • -flto: Link-time optimization for better performance
  • -O3: Maximum optimization for the cracker

Implementing SHA256-NI: The Reference Implementation
#

We obtained a working SHA256-NI implementation from Jeffrey Walton’s SHA-Intrinsics project. This reference implementation uses Intel’s SHA extensions via C intrinsics.

The Core sha256_process_x86 Function
#

Here’s the reference implementation that processes multiple 64-byte blocks:

void sha256_process_x86(uint32_t state[8], const uint8_t data[], uint32_t length)
{
    __m128i STATE0, STATE1;
    __m128i MSG, TMP;
    __m128i MSG0, MSG1, MSG2, MSG3;
    __m128i ABEF_SAVE, CDGH_SAVE;
    const __m128i MASK = _mm_set_epi64x(0x0c0d0e0f08090a0bULL, 0x0405060700010203ULL);

    /* Load initial hash values */
    TMP = _mm_loadu_si128((const __m128i*) &state[0]);
    STATE1 = _mm_loadu_si128((const __m128i*) &state[4]);

    /* Reorder state for SHA256RNDS2 instruction */
    TMP = _mm_shuffle_epi32(TMP, 0xB1);          /* CDAB */
    STATE1 = _mm_shuffle_epi32(STATE1, 0x1B);    /* EFGH */
    STATE0 = _mm_alignr_epi8(TMP, STATE1, 8);    /* ABEF */
    STATE1 = _mm_blend_epi16(STATE1, TMP, 0xF0); /* CDGH */

    while (length >= 64)
    {
        /* Save current state for later combination */
        ABEF_SAVE = STATE0;
        CDGH_SAVE = STATE1;

        /* Rounds 0-3 */
        MSG = _mm_loadu_si128((const __m128i*) (data+0));
        MSG0 = _mm_shuffle_epi8(MSG, MASK);
        MSG = _mm_add_epi32(MSG0, _mm_set_epi64x(0xE9B5DBA5B5C0FBCFULL, 0x71374491428A2F98ULL));
        STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
        MSG = _mm_shuffle_epi32(MSG, 0x0E);
        STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);

        /* Rounds 4-7 through 60-63 (similar pattern) */
        // ... (64 rounds total using SHA256RNDS2, SHA256MSG1, SHA256MSG2)

        /* Combine state */
        STATE0 = _mm_add_epi32(STATE0, ABEF_SAVE);
        STATE1 = _mm_add_epi32(STATE1, CDGH_SAVE);

        data += 64;
        length -= 64;
    }

    /* Restore state to normal order */
    TMP = _mm_shuffle_epi32(STATE0, 0x1B);       /* FEBA */
    STATE1 = _mm_shuffle_epi32(STATE1, 0xB1);    /* DCHG */
    STATE0 = _mm_blend_epi16(TMP, STATE1, 0xF0); /* DCBA */
    STATE1 = _mm_alignr_epi8(STATE1, TMP, 8);    /* ABEF */

    /* Store result */
    _mm_storeu_si128((__m128i*) &state[0], STATE0);
    _mm_storeu_si128((__m128i*) &state[4], STATE1);
}

Explanation: This function uses Intel SHA-NI instructions (_mm_sha256rnds2_epu32, _mm_sha256msg1_epu32, _mm_sha256msg2_epu32) to process SHA256 compression in hardware. The key intrinsics are:

  • _mm_sha256rnds2_epu32: Two rounds of SHA256
  • _mm_sha256msg1_epu32: First message schedule optimization
  • _mm_sha256msg2_epu32: Second message schedule optimization

The Wrapper Function (Initial Broken Version)
#

Our initial wrapper function had a critical bug:

void sha256_hash(const uint8_t* data, size_t len, uint8_t* out) {
    uint32_t state[8];
    state[0] = 0x6a09e667;
    state[1] = 0xbb67ae85;
    state[2] = 0x3c6ef372;
    state[3] = 0xa54ff53a;
    state[4] = 0x510e527f;
    state[5] = 0x9b05688c;
    state[6] = 0x1f83d9ab;
    state[7] = 0x5be0cd19;
    
    size_t remaining = len % 64;
    size_t full_blocks = len - remaining;
    
    if (full_blocks > 0) {
        sha256_process_x86(state, data, full_blocks);
    }
    
    uint8_t block[64];
    memset(block, 0, 64);
    if (remaining > 0) {
        memcpy(block, data + full_blocks, remaining);
    }
    block[remaining] = 0x80;
    
    if (remaining >= 56) {
        sha256_process_x86(state, block, 64);
        memset(block, 0, 64);
    }
    
    uint64_t bitlen = len * 8;
    block[56] = (bitlen >> 56) & 0xff;
    block[57] = (bitlen >> 48) & 0xff;
    block[58] = (bitlen >> 40) & 0xff;
    block[59] = (bitlen >> 32) & 0xff;
    block[60] = (bitlen >> 24) & 0xff;
    block[61] = (bitlen >> 16) & 0xff;
    block[62] = (bitlen >> 8) & 0xff;
    block[63] = bitlen & 0xff;
    
    sha256_process_x86(state, block, 64);
    
    // BUG IS HERE: This produces wrong byte order!
    memcpy(out, state, 32);
}

The Problem: On little-endian x86 systems, memcpy(out, state, 32) copies each 32-bit word as-is. If state[0] = 0xe3b0c442, it’s stored in memory as bytes 42 c4 b0 e3 (least significant byte first). But SHA256 output should be e3 b0 c4 42 (most significant byte first).


The Debugging Journey
#

First Sign Something Was Wrong
#

We tested with the empty string first (the simplest test case):

Expected: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Got:      42c4b0e3141cfc98c8f4fb9a24b96f99e441ae274c939b641b9995a455b85278

Notice the first four bytes: 42 c4 b0 e3 vs e3 b0 c4 42 — completely reversed!

Confirming the Reference Implementation Works
#

We compiled the noloader reference code standalone:

gcc -DTEST_MAIN -msha -march=native -o sha256_test sha256-x86.c && ./sha256_test
# Output: SHA256 hash of empty message: E3B0C44298FC1C14...
# Success!

The reference worked perfectly. This meant our integration was wrong somehow.

Comparing Identical Code in Different Contexts
#

We created a test with both calling patterns in the same file:

// Test 1: Using wrapper function
sha256_hash_wrapper((const uint8_t*)"", 0, hash);
printf("Result: %s\n", hash);

// Test 2: Direct call (noloader style)
uint8_t message[64];
memset(message, 0, 64);
message[0] = 0x80;
sha256_process_x86(state, message, 64);
// Print state...

Output:

=== Using wrapper ===
Result: 42c4b0e3141cfc98c8f4fb9a24b96f99e441ae274c939b641b9995a455b85278

=== Using noloader style ===
Result: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Same function, same input data, completely different results! The data being passed was verified to be identical.

The Smoking Gun
#

We added debug output to print the state after sha256_process_x86:

State words (big-endian print):
state[0] = 0xe3b0c442 -> e3b0c442
state[1] = 0x98fc1c14 -> 98fc1c14
...

Raw bytes via memcpy:
42c4b0e3141cfc98c8f4fb9a24b96f99e441ae274c939b641b9995a455b85278

The state words are correct (0xe3b0c442), but when we do memcpy(out, state, 32) on a little-endian machine, the bytes get reversed in memory.


The Fix: Explicit Big-Endian Output
#

The fix is straightforward: manually extract each byte from each 32-bit word in the correct order:

void sha256_hash(const uint8_t* data, size_t len, uint8_t* out) {
    uint32_t state[8];
    state[0] = 0x6a09e667;
    state[1] = 0xbb67ae85;
    state[2] = 0x3c6ef372;
    state[3] = 0xa54ff53a;
    state[4] = 0x510e527f;
    state[5] = 0x9b05688c;
    state[6] = 0x1f83d9ab;
    state[7] = 0x5be0cd19;
    
    // ... padding and processing ...
    
    sha256_process_x86(state, block, 64);
    
    /* sha256_process_x86 stores state as uint32_t in native (little-endian)
       byte order, but SHA256 output needs big-endian. Byte-swap each word. */
    for (int i = 0; i < 8; i++) {
        uint32_t w = state[i];
        out[i*4+0] = (w >> 24) & 0xff;  // MSB first
        out[i*4+1] = (w >> 16) & 0xff;
        out[i*4+2] = (w >>  8) & 0xff;
        out[i*4+3] = (w      ) & 0xff;  // LSB last
    }
}

Complete fish_crypto.c Implementation
#

Here’s the final working implementation:

#include <string.h>
#include <stdint.h>
#include <x86intrin.h>
#include <openssl/sha.h>
#include <openssl/evp.h>
#include "fish_crypto.h"

void sha256_process_x86(uint32_t state[8], const uint8_t data[], uint32_t length);

void sha256_hash(const uint8_t* data, size_t len, uint8_t* out) {
    uint32_t state[8] = {
        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
        0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
    };

    size_t remaining = len % 64;
    size_t full_blocks = len - remaining;

    if (full_blocks > 0) {
        sha256_process_x86(state, data, full_blocks);
    }

    uint8_t block[64];
    memset(block, 0, 64);
    if (remaining > 0) {
        memcpy(block, data + full_blocks, remaining);
    }
    block[remaining] = 0x80;

    if (remaining >= 56) {
        sha256_process_x86(state, block, 64);
        memset(block, 0, 64);
    }

    uint64_t bitlen = len * 8;
    block[56] = (bitlen >> 56) & 0xff;
    block[57] = (bitlen >> 48) & 0xff;
    block[58] = (bitlen >> 40) & 0xff;
    block[59] = (bitlen >> 32) & 0xff;
    block[60] = (bitlen >> 24) & 0xff;
    block[61] = (bitlen >> 16) & 0xff;
    block[62] = (bitlen >> 8) & 0xff;
    block[63] = bitlen & 0xff;

    sha256_process_x86(state, block, 64);

    /* The fix: byte-swap each word from native little-endian to big-endian */
    for (int i = 0; i < 8; i++) {
        uint32_t w = state[i];
        out[i*4+0] = (w >> 24) & 0xff;
        out[i*4+1] = (w >> 16) & 0xff;
        out[i*4+2] = (w >>  8) & 0xff;
        out[i*4+3] = (w      ) & 0xff;
    }
}

/* sha256_process_x86 omitted for brevity -- see noloader/SHA-Intrinsics */

void key_from_password(const char* password, char* key) {
    uint8_t digest[32];
    sha256_hash((const uint8_t*)password, strlen(password), digest);
    for (int i = 0; i < 40872; i++) {
        sha256_hash(digest, 32, digest);
    }
    memcpy(key, digest, 32);
}

void key_hash(const char* key, char* a_hash) {
    uint8_t digest[32];
    memcpy(digest, key, 32);
    for (int i = 0; i < 30752; i++) {
        sha256_hash(digest, 32, digest);
    }
    memcpy(a_hash, digest, 32);
}

void compute_fish_hash_raw(const char* password, uint8_t* out) {
    char key[32];
    key_from_password(password, key);
    key_hash(key, (char*)out);
}

Parallel Cracking: Partitioning the Search Space
#

With a working SHA256-NI implementation, the next step was building a multithreaded brute-force cracker. The key design decision is how to partition the search space across threads without any synchronization overhead.

Search Space as a Flat Index
#

Every possible password of a given length over a given charset can be mapped to a unique integer index. For a charset of size C and password length L, the total search space is C^L candidates, numbered 0 through C^L - 1. Converting an index back to a password is essentially base conversion:

static void generate_password(uint64_t idx, char* buf, int len,
                              const char* charset, int charset_len) {
    for (int i = len - 1; i >= 0; i--) {
        buf[i] = charset[idx % charset_len];
        idx /= charset_len;
    }
    buf[len] = '\0';
}

Index 0 maps to "aaaaaaaa", index 1 to "aaaaaaab", and so on. This mapping is deterministic and requires no shared state between threads.

Contiguous Range Partitioning
#

The total index range [0, C^L) is divided into N contiguous chunks, one per thread. Thread t gets indices [t * chunk_size, (t+1) * chunk_size), with the last thread absorbing any remainder from integer division:

uint64_t per_thread = total_combinations / num_threads;

for (int t = 0; t < num_threads; t++) {
    args[t].start_idx = (uint64_t)t * per_thread;
    args[t].end_idx   = (t == num_threads - 1)
                        ? total_combinations
                        : (uint64_t)(t + 1) * per_thread;
    pthread_create(&threads[t], NULL, work, &args[t]);
}

This approach has several advantages over alternatives like round-robin or work-stealing:

  • Zero synchronization: Each thread owns its range exclusively. No mutexes, no atomics, no cache-line contention on shared counters.
  • Perfect load balance: Each thread gets total / N candidates (plus at most one extra for the last thread). Since every candidate costs exactly 71,624 SHA256 iterations, the work per candidate is uniform.
  • Early termination: When any thread finds the password, it sets a shared volatile int flag. All other threads check this flag each iteration and bail out.

The Worker Function
#

Each thread runs the same tight loop – generate a password from the index, compute 71,624 SHA256 iterations, and compare the raw 32-byte hash against the precomputed target:

static void* work(void* arg) {
    thread_arg_t* targ = (thread_arg_t*)arg;
    char password[64];
    uint8_t hash[32];

    for (uint64_t idx = targ->start_idx;
         idx < targ->end_idx && !*(targ->found); idx++) {
        generate_password(idx, password, targ->password_len,
                          targ->charset, targ->charset_len);
        compute_fish_hash_raw(password, hash);

        if (memcmp(hash, targ->target_hash, 32) == 0) {
            *(targ->found) = 1;
            strcpy(targ->result, password);
            printf("[Thread %d] FOUND: %s\n", targ->thread_id, password);
        }
    }
    return NULL;
}

The target hash is decoded from base64 once at startup and compared as raw bytes with memcmp – avoiding a base64 encode on every candidate.

Custom Charsets
#

Besides the built-in lower, alnum, and print charsets, the cracker accepts any arbitrary string as a custom charset. This is what made the final crack feasible – knowing the password only used the letters k, l, m, j, a reduced the search space from 26^8 (~209 billion) to 5^8 (390,625):

$ ./crack klmja 'MY4yTMnzysehj/026egypIFhS8hM62IikZIWMGZr5vs=' 8 20

Searching klmja password of length 8
Total combinations: 390625
Target hash: MY4yTMnzysehj/026egypIFhS8hM62IikZIWMGZr5vs=
Using 20 threads

[Thread 3] FOUND: kalamaja

--- Results ---
PASSWORD FOUND: kalamaja
Time: 29.26 seconds (wall clock)

Performance: The E-Core Surprise
#

LTO Makes or Breaks Everything
#

The single most impactful optimization turned out to be -flto (link-time optimization). Without LTO, the compiler cannot inline sha256_process_x86 into sha256_hash, or sha256_hash into the iteration loops. Every one of the 71,624 SHA256 calls per candidate pays full function-call overhead: spilling registers to the stack, re-loading the SHA256 state, and setting up the padding block.

With LTO, the compiler sees the entire call chain as one unit: compute_fish_hash_raw → key_from_password/key_hash → sha256_hash → sha256_process_x86. It inlines everything, keeps the SHA256 state in XMM registers across iterations, and eliminates redundant memory traffic. The result:

Per SHA256 call Per candidate (71,624 calls)
Without LTO 4,300 ns 308 ms
With LTO 43 ns 3.1 ms
Speedup 100x 100x

This 100x speedup from a single compiler flag is the difference between 3 candidates/sec and 340 candidates/sec on a single core – the difference between “completely impractical” and “cracks in 29 seconds.”

P-Cores vs E-Cores: Raptor Lake’s Split Personality
#

Running on an Intel i7-14700K (8 P-cores + 12 E-cores), we discovered a dramatic asymmetry in SHA256-NI performance between the two core types. The Gracemont E-cores execute SHA256-NI instructions with ~100x lower latency than the Raptor Cove P-cores:

                    SHA256-NI throughput (single thread, with LTO)
                    ────────────────────────────────────────────────
P-core (CPU 0):     232 K SHA256/sec    ~  3 candidates/sec
E-core (CPU 16):   24.5 M SHA256/sec    ~342 candidates/sec
                                           ───────────────
                                           105x faster

The effect on thread scaling is dramatic. With the default Linux scheduler, low thread counts land on P-cores (slow), but as thread count increases past 8, the scheduler starts placing threads on E-cores:

FiSH password candidate benchmark (10 s per test, i7-14700K)
Each candidate = 71,624 SHA256 iterations

Threads  Cand/sec    SHA256/sec     Scaling
───────  ────────    ──────────     ───────
   1          3     243.7 K/s      1.0x  █
   2          8     545.8 K/s      2.2x  █
   4         15       1.1 M/s      4.4x  █
   8         48       3.4 M/s     14.1x  █
  14       2043     146.3 M/s    600.4x  ████████████████████
  20       3995     286.1 M/s   1174.1x  ████████████████████████████████████████
  28       4017     287.7 M/s   1180.5x  ████████████████████████████████████████

The “600x speedup” from 1 to 14 threads isn’t linear scaling – it’s the E-cores coming online. The P-cores contribute negligibly to total throughput.

E-Cores Only: Linear Scaling
#

Pinning threads to E-cores (CPUs 16-27) shows clean linear scaling up to the 12 available E-cores:

E-cores only (taskset -c 16-27)

Threads  Cand/sec    SHA256/sec     Scaling
───────  ────────    ──────────     ───────
   1        342      24.5 M/s      1.0x  ███
   2        684      49.0 M/s      2.0x  ███████
   4       1367      97.9 M/s      4.0x  █████████████
   8       2734     195.8 M/s      8.0x  ███████████████████████████
  12       4101     293.7 M/s     12.0x  ████████████████████████████████████████

Peak throughput: ~4,100 candidates/sec across 12 E-cores, or ~294 million SHA256 compressions per second. The P-cores add essentially nothing – their 44 cand/sec combined is noise compared to the E-cores’ 4,100.


Key Takeaways
#

  1. Endianness is the silent killer in crypto implementations. The SHA256-NI intrinsics operate on uint32_t state words in the CPU’s native byte order. On x86 (little-endian), memcpy-ing the state array directly to a byte buffer reverses each 4-byte word. The fix is trivial – explicit shifts to extract bytes in big-endian order – but the bug is invisible unless you carefully compare byte-level output against a known test vector.

  2. Always verify against a known test vector before doing anything else. The empty-string SHA256 hash (e3b0c442...) and the “foobar” FiSH hash gave us an unambiguous pass/fail signal. Without these, we would have been chasing phantom bugs in the cracker logic.

  3. The noloader/SHA-Intrinsics reference is trustworthy. The standalone reference code worked perfectly. The bug was entirely in our glue code between the low-level block processor and the high-level hash function. When integrating third-party crypto primitives, the integration layer is where bugs hide.

  4. -flto can be a 100x multiplier for iterated crypto. Without link-time optimization, the compiler cannot inline the SHA256 compression function into the iteration loop. Each of the 71,624 calls per candidate pays full function-call overhead: register spills, stack setup, re-loading state from memory. With LTO, the entire chain collapses into a tight loop with state kept in XMM registers. The difference is 4,300 ns vs 43 ns per SHA256 call – one compiler flag turning “impractical” into “29 seconds.”

  5. Not all cores are created equal – even on the same chip. On the i7-14700K, the Gracemont E-cores execute SHA256-NI 105x faster than the Raptor Cove P-cores. The 12 E-cores deliver 4,100 candidates/sec while the 8 P-cores manage only 44. For SHA256-NI workloads, the “efficiency” cores are the performance cores. If your brute-forcer isn’t pinned to the right cores, you’re leaving 99% of throughput on the table.

  6. Narrowing the search space matters more than raw speed. Even at ~4,100 candidates/sec, brute-forcing 26^8 lowercase passwords would take ~14 hours. Knowing the charset was just 5 characters reduced the search space from 209 billion to 390,625 – turning hours into seconds. Domain knowledge about the password (language, character set, length) is the real force multiplier.

  7. Contiguous range partitioning is the simplest correct approach for uniform-cost brute force. When every candidate costs the same amount of work, you don’t need work-stealing queues or dynamic scheduling. Divide the index space evenly, give each thread its slice, and let them run. The only shared state is a single volatile int flag for early termination.