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.
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:
- Parent process (core 0): Manages
io_uring, submits reads, drains completions, dispatches ready chunks to workers via a lock-free queue, collects processed chunks from workers, submits writes - 5 worker processes (cores 2, 4, 6, 8, 10): Each spins on a lock-free queue, grabs a chunk, encrypts it with AVX2, pushes it to the write queue
- Lock-free MPMC queues: Vyukov bounded multi-producer multi-consumer queue. No locks. No mutexes. Just atomic CAS operations on a circular buffer.
- Shared memory: Key tables and chunk buffers are in
mmap'd shared memory. Zero copy. Workers read directly from the buffer the kernel DMA'd into.
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.
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.
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.
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();
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 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.
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.
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).
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.
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.
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:
- Parent core does no crypto: Core 0 is dedicated to I/O management. That caps theoretical scaling at 5/6 = 83% even with zero overhead.
- Workers idle ~25%: Waiting for the I/O pipeline to deliver chunks. The pipeline depth (16 reads + 16 writes in flight) isn't always enough to keep all 5 workers saturated.
- L3 cache contention: My Ryzen 4600H has two CCX modules, each with 4 cores and 4MB L3. Workers on the same CCX share 4MB L3, and the 8 MiB chunk buffers + key tables don't all fit. There's inevitable L3 pressure.
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:
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:
- Measure, don't assume. Threads vs processes, SQPOLL vs explicit submit, buffered vs
O_DIRECT — in every case, conventional wisdom or theory pointed one way and the hardware pointed another.
perf statandperf recordare your best friends. - Every "optimization" is a hypothesis. SQPOLL was supposed to eliminate syscalls. Instead it introduced 46,000 context switches. The page cache was supposed to speed up reads. Instead it added a 37% overhead. Don't trust the pitch — measure the result.
- One instruction can save billions. A single
PAUSEinstruction cut 48% of wasted instructions. The smallest changes can have the biggest impact when they're at the right place. - Know your workload. Streaming I/O is not random access. Sequential reads are not database queries. The page cache that's essential for a database is pure overhead for a one-pass encryption pipeline. Optimize for your actual access pattern, not the general case.
- Your power profile is part of the benchmark. A 30% frequency difference from battery throttling can mask real performance changes. Always control your power state.
- Understand contention before optimizing data structures. SPMC/MPSC queues are theoretically cheaper than MPMC, but if the contention is on the multi-access side (which it was), specialization doesn't help. Know where the cache line bounces actually happen.
- PGO is free performance. Three compile phases, one representative run, and the compiler does the rest. No code changes needed. Just don't forget to re-profile after every change.
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:
- NUMA-aware buffer allocation — on multi-socket systems, ensuring chunk buffers are local to the worker's NUMA node
- Larger pipeline depth — more in-flight reads to keep workers saturated, at the cost of more memory
- AVX-512 — if I had a CPU that supported it, 512-bit registers would double the SIMD width again. But my Ryzen 4600H doesn't have AVX-512, and AMD's implementation is double-pumped anyway, so the gains would be modest
- I/O pipeline tuning — experimenting with
io_uringparameters likeIORING_SETUP_COOP_TASKRUNand registered buffers (IORING_REGISTER_BUFFERS) to reduce even more kernel overhead
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.