2025 · 20 min read · Low-Latency-Lab

From 0.61 to 1.33 GB/s: Building TeraCrypt v4.0

A nerd's journey through io_uring, AVX2 SIMD, lock-free queues, and the art of discovering your own code is wasting half the CPU.

io_uring AVX2 Lock-free O_DIRECT perf PGO Multi-process

Look, I need to tell you a story. It's about how I rebuilt my encryption engine from scratch, learned a bunch of things the hard way, and then spent an embarrassing number of hours staring at perf record output at 3am trying to figure out why my "highly optimized" multi-core code was barely 2x faster than single-threaded.

This is the story of TeraCrypt v4.0 — the version that actually works. Not because I'm some genius who got it right the first time, but because I got it wrong, measured it, and then fixed it. One painful, exciting, obsessive bottleneck at a time.

* * *

The Itch

A few years ago, I wrote Bit-level-encryption-v1.0 — a custom cipher in C. It was my first real attempt at "optimization." I thought -O3 was aggressive. I thought checking the assembly output to make sure the compiler was doing the right thing was peak performance engineering. I was naive, but I was enthusiastic, and honestly? That enthusiasm was the most important part.

Then I built TeraCrypt v2.1 — a Python frontend that distributed file chunks across cores using a C shared library with inline assembly. It worked. It was faster. But it was also... kind of a hack. Python was doing the orchestration, which meant GIL contention, serialization overhead, and a lot of glue code that had no business being in a performance-critical pipeline.

Over the years, I learned a lot. CPU architecture. Cache hierarchies. How branch prediction actually works. What the memory bus does when 5 cores all try to CAS the same cache line. I read about io_uring and thought "holy crap, this changes everything." I'd known about SIMD for years but never used it this extensively. I knew about async I/O in theory but had never built something that combined async I/O with a multi-process worker pool.

So I decided to rebuild everything. From scratch. In pure C. No Python glue. No training wheels. Just me, the hardware, and a compiler.

I called it Low-Latency-Lab — a place to experiment, learn, and optimize the shit out of my older projects with everything I now knew about how the hardware actually works.

* * *

Threads vs Processes: The First Experiment

Before I wrote a single line of the pipeline, I had a decision to make: threads or processes?

Everyone says threads are lighter weight. Everyone says shared address space is a feature. But here's the thing — I'd been burned by the GIL in Python, and I'd read enough about false sharing and cache line ping-pong to be suspicious. I wanted to measure it, not just trust conventional wisdom.

So I built two prototypes. Same crypto core, same chunk size, same I/O pattern. One used pthreads with a shared buffer pool, the other used fork() with shared memory (mmap + MAP_SHARED) for the data buffers and key tables, and lock-free queues for communication.

The results were... interesting. The process version was faster. Not by a little — by a meaningful margin. And the more workers I added, the bigger the gap got.

Why processes won

With threads, every worker shares the same address space. That sounds good until you realize that the OS scheduler can preempt a thread mid-encryption, and when it resumes, it might be on a different core with a cold L1/L2 cache. With processes pinned to specific cores via sched_setaffinity, each worker owns its core. No preemption surprises. No cache migrations. The OS scheduler has less to manage because each process is pinned.

Also: shared memory via mmap with MAP_SHARED gives you the same zero-copy buffer sharing as threads, but with explicit control over what's shared. The key tables and chunk buffers are shared. The queues are shared. Everything else is private. No accidental false sharing from stack variables or compiler-generated temporaries landing on the same cache line.

That was lesson number one: measure, don't assume. Conventional wisdom said threads. The hardware said processes. I listened to the hardware.

* * *

Falling Down the io_uring Rabbit Hole

Okay, this is where things get fun. I'd been reading about io_uring for months — Jens Axboe's new async I/O interface for Linux that was supposed to make epoll and aio look like toys. The pitch was seductive: submit I/O requests to a ring buffer shared between userspace and the kernel, and the kernel processes them asynchronously. No syscalls per request. No copying. Just a memory-mapped ring buffer and a handshake.

I'd used read() and write() my entire career. Blocking I/O. Simple. Easy. And for a single-threaded program, totally fine. But I was building a pipeline now — one process reading chunks, 5 processes encrypting them, one process writing them back. If the parent blocks on read(), the workers starve. If the parent blocks on write(), the pipeline stalls.

The old TeraCrypt v2.1 solved this with Python's multiprocessing and thread pools. It worked, but it was layers of abstraction on top of abstraction. I wanted to get closer to the metal.

So I went all in on io_uring. And let me tell you — the first time you set up a ring buffer, submit a read request, and get a completion notification without a single syscall in the hot path... it feels like magic. You're talking to the kernel through shared memory. You're literally writing to a ring buffer in your process's address space and the kernel is reading from it. No read() syscall. No write() syscall. Just io_uring_submit to flush the submission queue tail pointer, and io_uring_peek_cqe to check for completions.

// The hot loop: submit reads, drain completions, feed workers
while (inflight_reads < MAX_INFLIGHT_READS && free_count > 0) {
    struct io_uring_sqe* sqe = io_uring_get_sqe(&uring);
    io_uring_prep_read(sqe, fd_in, slot->buf, CHUNK_SIZE, offset);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)pack_ud(OP_READ, slot_idx));
    inflight_reads++;
}
io_uring_submit(&uring);
 
// Later: check for completions
struct io_uring_cqe* cqe;
while (io_uring_peek_cqe(&uring, &cqe) == 0) {
    // cqe->res has the result, cqe->user_data has our slot index
    // Feed the completed chunk to a worker via the read_queue
    io_uring_cqe_seen(&uring, cqe);
}

The architecture I ended up with was a proper async pipeline:

16 reads in flight. 16 writes in flight. 37 chunk slots (5 workers + 16 read + 16 write). 296 MiB of buffer pool. This thing was designed.

Side note: the Vyukov MPMC queue is a beautiful piece of work. Each cell in the circular buffer has a sequence number. Producers and consumers use atomic CAS on the enqueue/dequeue positions, but the sequence number tells you whether the cell is valid for your operation — no locks needed. It's the kind of data structure that makes you appreciate how much thought goes into lock-free programming.
* * *

AVX2: My First Real SIMD Deep Dive

I'd known about SIMD for years. I'd written a few _mm_add_epi32 calls here and there. But I'd never designed a crypto core around SIMD from the ground up. This time, I did.

The cipher operates on 128-byte blocks — 32 uint32_t words. Each round does bitwise rotations, XORs, and byte-level transpositions. In scalar code, each rotation is a rol instruction, each XOR is a xor, and the transpose is a nightmare of shifts and masks.

But with AVX2, a 256-bit YMM register holds 8 uint32_t values. So I can do 8 rotations in one instruction, 8 XORs in one instruction. The transpose — which is the most expensive part — becomes a series of _mm256_shuffle_epi8 and _mm256_unpacklo/hi_epi8 calls that handle 8 bytes at a time across 4 registers.

And here's the trick that I'm most proud of: two-block parallel encryption. Instead of encrypting one 128-byte block at a time, I encrypt two blocks simultaneously using different AVX2 lanes. The key tables are the same for both blocks, so I load them once and apply them to two different data registers. This doubles the instruction-level parallelism without doubling the key table loads.

// Two blocks in parallel: same key tables, different data
__m256i k0 = _mm256_loadu_ps((__m256i*)&key_tables->round0[0]);
__m256i b0_a = _mm256_loadu_ps((__m256i*)(block_a + 0));   // block A, words 0-7
__m256i b0_b = _mm256_loadu_ps((__m256i*)(block_b + 0));   // block B, words 0-7
b0_a = _mm256_xor_ps(b0_a, k0);  // XOR both with same key
b0_b = _mm256_xor_ps(b0_b, k0);
// ... rotations, transpositions, all applied to both blocks
// ... using the same key table loads

The single-threaded version of this cipher hits 0.39 GB/s on my Ryzen 5 4600H at 4.0 GHz. That's ~24 instructions per byte, with an IPC of ~2.4. Not bad for a custom cipher. But 0.39 GB/s on a single core means the theoretical 5x scaling would give me ~1.96 GB/s. That was the target.

* * *

The First Benchmark: A Reality Check

I built it. I PGO-compiled it. I dropped caches. I ran it on a 4GB file.

First Benchmark — 4GB File, 6 Cores, PGO
Single-threaded encrypt 0.30 GB/s (11.3s)
Multi-threaded encrypt 0.61 GB/s (7.0s)
Scaling 2.1x (on 5 workers!)

2.1x. On 5 workers. That's 42% efficiency.

I stared at that number for a long time. I'd built this elaborate async pipeline with io_uring and lock-free queues and shared memory and CPU pinning and PGO and AVX2... and I got 2.1x. My old Python-based TeraCrypt v2.1 got similar numbers. All that engineering for basically nothing?

Something was deeply wrong. And I needed to find out what.

* * *

perf record: The Detective Story

This is where the real fun started. I fired up perf and went full detective mode.

sudo perf record -F 999 -g --call-graph dwarf \
  -o teracrypt_enc.data \
  ./teracrypt encrypt 4.data 4.enc test.key
 
sudo perf report --children
sudo perf script
sudo perf annotate

The first thing perf stat told me was that the CPU frequency was dropping under all-core load. My Ryzen 4600H has a base clock of 3.0 GHz and boosts to 4.0 GHz on single-core. But with all 6 cores loaded, it was dropping to ~2.4 GHz. That's a 40% frequency penalty. On battery. (More on this later — plugging in the power cable changed everything.)

But frequency alone didn't explain 2.1x. Even at 2.4 GHz, I should've been getting at least 3x. Something else was eating my performance.

Then I looked at the instruction count.

perf stat — Instruction Analysis
Single-threaded instructions 76.6 billion
Multi-threaded instructions 199.6 billion
Extra instructions 123 billion (+161%)

123 billion extra instructions. The single-threaded version needed 76.6 billion instructions to encrypt 4GB. The multi-threaded version needed 199.6 billion. That's 2.6x more instructions for the same work. My workers were executing 123 billion instructions that did absolutely nothing useful.

Where were they going? perf annotate gave me the answer: spin loops.

* * *

Fix #1: The Spin Loop That Ate My CPU

Here's what the worker loop looked like:

for (;;) {
    uint32_t slot_idx;
    if (mpmc_queue_pop(w->read_queue, &slot_idx)) {
        // Got work — encrypt the chunk
        process_region(s->buf, s->padded_size, w->mode, w->key_tables);
        while (!mpmc_queue_push(w->write_queue, slot_idx)) { }
    }
    // Queue empty? Immediately try again. And again. And again.
    // At ~1 billion times per second. Per worker. 5 workers.
    // That's 5 billion CAS attempts per second on the same cache line.
}

When the queue was empty, the worker would immediately retry. No sleep, no backoff, no pause. Just a tight loop hammering atomic_compare_exchange_weak on the dequeue position. Each retry is a CAS instruction, which is ~10-20 cycles, plus a cache line bounce if another core has it.

5 workers doing this simultaneously on the same queue's dequeue_pos cache line = cache line ping-pong from hell. Every CAS from one worker invalidates the cache line on all other cores. They all retry, all miss, all bounce the line again. It's a thundering herd on a lock-free data structure.

The fix was almost insultingly simple: one instruction.

} else {
    // Queue empty? Pause. One instruction. ~10 cycles.
    // Drops the retry rate by ~10x. Cuts wasted instructions
    // and memory-bus traffic dramatically.
    __builtin_ia32_pause();
}

__builtin_ia32_pause() emits a single PAUSE instruction. On Intel, it's a hint to the CPU that you're in a spin loop — it reduces power consumption and lets the other hyperthread (if any) use the execution resources. On AMD Zen, it's a ~10 cycle delay instead of the ~1 cycle a tight spin would take. That's 10x fewer CAS attempts per second. 10x less cache line contention. 10x less memory bus traffic.

I also applied the same fix to the parent process. The parent was busy-waiting on io_uring_peek_cqe — continuously polling for completions even when nothing had happened. And it was calling io_uring_submit on every iteration, even when no new SQEs had been added.

// Parent: only submit when we actually added SQEs
if (new_sqes)
    io_uring_submit(&uring);
 
// Parent: pause when nothing happened this iteration
if (!got_cqe && !new_sqes)
    __builtin_ia32_pause();
After Fix #1 — Pause Backoff
Throughput 0.66 GB/s (+8%)
Instructions executed 103.1 billion (-48%)
Context switches 38,409 (-26%)

48% fewer instructions. The workers were no longer burning billions of CAS operations on empty queues. But throughput only went up 8% — because the remaining bottleneck was still there, hiding behind the spin loops.

* * *

Fix #2: SQPOLL — The "Optimization" That Wasn't

This is the one that hurt.

When I set up io_uring, I used IORING_SETUP_SQPOLL — the kernel's "submit queue polling" mode. The idea is beautiful: instead of the userspace process calling io_uring_submit() (which does a io_uring_enter syscall), a kernel thread continuously polls the submission queue. Zero syscalls for submissions. The kernel thread just watches the ring buffer and processes SQEs as they appear.

I also pinned this kernel thread to core 0 with IORING_SETUP_SQ_AFF. Same core as the parent process. SMT was disabled on my machine (cores 1, 3, 5, 7, 9, 11 were offline), so there was no hyperthread to share with.

Can you see the problem yet? Because I didn't. Not for a long time.

The SQPOLL kernel thread and the parent process were both pinned to core 0. They were time-slicing that core via context switches. Every time the kernel thread wanted to check the submission ring, it preempted the parent. Every time the parent wanted to drain completions, it preempted the kernel thread. Back and forth. Thousands of times per second.

The Smoking Gun — Context Switches with SQPOLL
Context switches (with SQPOLL) 46,797
Context switches (without SQPOLL) 15,813 (-66%)
task-clock (with SQPOLL) 29,588 ms
task-clock (without SQPOLL) 17,651 ms (-40%)

The SQPOLL kernel thread was stealing ~40% of core 0's time spinning on an often-empty submission ring. The parent was fighting it for the remaining 60%. They were actively hurting each other.

The fix? Remove SQPOLL entirely. Just use regular io_uring with explicit io_uring_submit() calls. Yes, that means a syscall per submission batch. But io_uring_enter is cheap (~1-2 microseconds), and we only call it when we actually have SQEs to submit. The parent gets 100% of core 0. No fighting. No context switches.

After Fix #2 — Removing SQPOLL
Throughput 0.97 GB/s (+47%)
Scaling 2.5x (was 2.1x)

From 0.66 to 0.97 GB/s. Nearly 50% improvement. From removing a single flag.

I'd spent hours reading about SQPOLL, thinking it was the smart choice. I'd specifically added it to avoid the io_uring_enter syscall. And it was the single biggest performance killer in the entire pipeline. The "optimization" was doing the opposite of optimizing.

Lesson number two: every "optimization" is a hypothesis until you measure it. SQPOLL sounded great in theory. In practice, on a 6-core machine with no SMT, it was a disaster. On a big server with 32 cores and lots of submissions, it might be perfect. Context matters. Measure everything.

* * *

Fix #3: O_DIRECT for Reads — Killing the Page Cache

At this point I was at 0.97 GB/s. Better, but still only 2.5x scaling on 5 workers. I was running out of obvious things to fix. So I went back to perf stat and looked at the cache numbers.

perf stat — Cache Analysis (Buffered Reads)
L1D misses 330 million
Cache misses (L2/L3) 103 million
sys time 3.65s

330 million L1D misses. 103 million L2/L3 misses. 3.65 seconds of sys time. That's a lot of kernel work for a program that's supposed to be CPU-bound on encryption.

And then it hit me. My reads were buffered. My writes were O_DIRECT.

I'd already added O_DIRECT to the output file to bypass the page cache for writes. But the input file? Still going through open(fd_in, O_RDONLY). Every pread was going: disk → kernel page cache → user buffer. That's an extra copy. Extra kernel bookkeeping for readahead. Extra cache pollution — the page cache was filling up with 4GB of file data that I was only going to read once and never touch again.

And I was even calling posix_fadvise(fd_in, ..., POSIX_FADV_DONTNEED) after each read to tell the kernel "hey, drop this from the page cache." But each posix_fadvise is another syscall. I was paying for the page cache entry, then paying again to evict it. Two syscalls per chunk instead of zero.

The fix: add O_DIRECT to the input file too.

int fd_in = open(input_file, O_RDONLY | O_DIRECT);
int using_direct_read = 1;
if (fd_in < 0) {
    using_direct_read = 0;
    fd_in = open(input_file, O_RDONLY);  // fallback
}

// ... later, only call posix_fadvise if NOT using O_DIRECT
// (O_DIRECT bypasses page cache entirely — no need to evict)
if (!using_direct_read)
    posix_fadvise(fd_in, offset, CHUNK_SIZE, POSIX_FADV_DONTNEED);

With O_DIRECT, the kernel DMA's directly into the user buffer. No intermediate page cache copy. No readahead bookkeeping. No posix_fadvise calls. The buffer just needs to be 4096-byte aligned (which it already was — I'd set up the chunk pool with posix_memalign and huge pages from the start).

After Fix #3 — O_DIRECT Reads
Throughput 1.33 GB/s (+37%)
sys time 0.68s (-81%)
L1D misses 104 million (-68%)
Cache misses (L2/L3) 36 million (-65%)
task-clock 13,000 ms (-27%)

From 0.97 to 1.33 GB/s. 5x less kernel time. 3x fewer cache misses. The page cache was silently eating 37% of my throughput, and I'd been compensating for it with posix_fadvise calls that were just adding more overhead on top.

Lesson number three: the page cache is not your friend for streaming workloads. If you're reading a file once, sequentially, and never touching it again, the page cache is just an expensive copy machine. O_DIRECT eliminates the middleman.

* * *

The Power Cable Discovery

Okay, this one's embarrassing but important. Remember how I mentioned the CPU frequency was dropping under all-core load? I was benchmarking on battery. My laptop was quietly throttling the CPU to prevent battery drain. All-core boost was capped at ~2.4 GHz instead of the 3.8-4.0 GHz it could do on single-core.

When I finally plugged in the power cable, the single-threaded baseline jumped from 0.30 GB/s to 0.39 GB/s. The multi-threaded version jumped from 1.33 to... well, it also improved because the workers were now running at higher frequencies too.

This is a critical lesson for anyone benchmarking on a laptop: your power profile is part of your benchmark. A 30% frequency difference changes everything. Always benchmark on AC power with the performance governor (cpupower frequency-set -g performance), and always note the power state in your results.

* * *

The SPMC/MPSC Question: When Specialization Doesn't Help

After all the fixes, I asked another AI to review my code. It pointed out something clever: my read_queue has exactly one producer (the parent) and N consumers (the workers) — that's SPMC. My write_queue has N producers (the workers) and one consumer (the parent) — that's MPSC. The reviewer suggested I could use specialized SPMC/MPSC queues instead of the general Vyukov MPMC queue, which would be cheaper and reduce contention.

It's a smart observation. But when I actually thought about it carefully, I realized it wouldn't help. Here's why:

For the read_queue (SPMC): a specialized SPMC queue would eliminate the CAS on the producer side (the parent's push). The parent could use a simple store instead of an atomic CAS. But the consumer side — where 5 workers are competing to pop — would still need CAS. That's where the contention actually is. The producer side has zero contention because there's only one producer.

For the write_queue (MPSC): a specialized MPSC queue would eliminate the CAS on the consumer side (the parent's pop). The parent could use a simple load. But the producer side — where 5 workers are competing to push — would still need CAS. Again, that's where the contention lives.

The total savings would be ~1024 CAS operations on the parent (512 pushes + 512 pops). At ~15 cycles per CAS, that's ~15,000 cycles. About 4 microseconds. Out of a 3.26-second runtime. That's 0.0001%.

The worker-side contention — the part that actually matters — is completely unchanged by SPMC/MPSC specialization. The workers still need CAS because they're competing with each other. Specialized queues don't help when the contention is on the multi-access side.

The takeaway

Specialized queue types are cheaper in theory, but you need to look at where the contention actually is. In my pipeline, the single-producer/single-consumer sides had zero contention to begin with. The multi-access sides — which are the expensive ones — are identical whether you use MPMC or SPMC/MPSC. The general Vyukov MPMC queue was already fine.

Lesson number four: understand where the contention actually is before optimizing. A queue type that's theoretically cheaper can be practically identical if the cheap side wasn't the bottleneck.

* * *

PGO: The Invisible Optimization

I should mention PGO — Profile-Guided Optimization — because it's been quietly doing heavy lifting this entire time.

With PGO, you compile your code in three phases: first, instrument the binary with profiling counters. Second, run it on representative data to collect a profile. Third, recompile using that profile to guide branch prediction, inlining, and code layout decisions.

# Phase 1: Instrument
clang -fprofile-instr-generate -fcoverage-mapping \
      -Ofast -flto -march=native main.c -o teracrypt_pgo

# Phase 2: Run on representative data
./teracrypt_pgo encrypt 4.data /dev/null test.key
# This generates .profraw files

# Phase 3: Merge profile and recompile
llvm-profdata merge -o merged.profdata *.profraw
clang -fprofile-instr-use=merged.profdata \
      -Ofast -flto -march=native main.c -o teracrypt

The compiler learns which branches are hot, which paths are taken most often, and how to lay out the code to minimize instruction cache misses. For a crypto core with tight loops and predictable branch patterns, PGO gives a meaningful boost — the compiler can inline aggressively where it matters and keep cold paths out of the hot code's instruction cache lines.

I PGO-compiled after every single change. Each fix got its own fresh profile. This wasn't just about getting the best binary — it was about making sure I was measuring the real impact of each change, not a stale profile's guess.

* * *

The Full Journey

Here's the complete optimization timeline. Every number is a cold run on a 4GB file, caches dropped, PGO-compiled, on AC power with the performance governor.

Complete Optimization Timeline
Original (SQPOLL + busy-spin) 0.61 GB/s (1.6x)
+ PAUSE backoff in spin loops 0.66 GB/s (+8%)
- SQPOLL removed 0.97 GB/s (+47%)
+ O_DIRECT reads 1.33 GB/s (+37%)
Total improvement +118%
Final Results — 4GB File, 6 Cores, AC Power
Single-threaded encrypt 0.39 GB/s (10.95s)
Multi-threaded encrypt 1.33 GB/s (3.01s)
Multi-threaded decrypt 1.23 GB/s (3.24s)
Encrypt scaling 3.4x (68% of ideal 5x)
Decrypt scaling 3.2x (63% of ideal 5x)

3.4x on 5 workers. 68% of theoretical linear scaling. Is that good? For a streaming I/O workload on a 6-core laptop with a shared L3 cache and a single NVMe drive? Yeah, I think it's pretty damn good.

The remaining gap to 5x isn't a bug — it's physics:

* * *

Cross-Compatibility: Making Sure It Actually Works

Performance is meaningless if your encryption is wrong. One of the first things I did was verify that the new AVX2 cipher in Low-Latency-Lab produces exactly the same output as the old single-threaded Bit-level-encryption-v1.0. Same cipher, same key tables, same rounds — just different implementations.

I wrote a cross-benchmark script that runs four tests:

1.
Bit-level encrypt → Bit-level decrypt (self-test)
2.
TeraCrypt encrypt → TeraCrypt decrypt (self-test)
3.
Bit-level encrypt → TeraCrypt decrypt (cross-test)
4.
TeraCrypt encrypt → Bit-level decrypt (cross-test)

All four pass. Files encrypted by either implementation decrypt correctly with the other. The crypto core is identical — only the I/O and parallelization layer changed.

* * *

What I Learned

This project taught me more about real-world performance engineering than any textbook or course ever could. Here's the distilled version:

* * *

What's Next

TeraCrypt v4.0 is at 1.33 GB/s with 3.4x scaling. The remaining gap to linear scaling is mostly hardware — L3 cache pressure, I/O pipeline saturation, and the parent core not doing crypto. To push further, I'd need to look at:

But honestly? The most exciting part wasn't the final number. It was the journey. Starting with a 2.1x scaling that made no sense, digging through perf output at 3am, finding a kernel thread silently stealing 40% of a core, discovering the page cache was eating a third of my throughput, and watching each fix land like a piece of a puzzle clicking into place.

That's the fun. Not the destination — the detective work. The moment you look at a perf annotate output and understand why your code is slow. That moment when you change one line and the throughput jumps 47%. That's what I live for.

Anyway. It's 3am again. I should probably sleep. But there's this thing in the perf report I want to look at one more time...

* * *

All benchmarks were run on an AMD Ryzen 5 4600H (6 cores / 12 threads, SMT disabled), 16GB DDR4, NVMe SSD, Linux 6.x, with caches dropped via echo 3 > /proc/sys/vm/drop_caches before each run. PGO-compiled with Clang using -Ofast -flto -march=native. The full source code is at github.com/GurudattaRK/Low-Latency-Lab.