Chapter 5: Parallelism Without the GIL
In Chapters 2-4, we built a columnar engine, wrapped it in Python, and added Arrow export. Every operation already uses Rayon’s par_iter(), but we have not yet explained why this works and how much it helps. This chapter goes deep into parallelism: why the GIL blocks it in Python, how ctypes bypasses the GIL, how Rayon distributes work, and how SIMD auto-vectorization multiplies throughput further.
Section 1: The GIL in Depth
Why the GIL Exists
Python uses reference counting for memory management. Every Python object has a field ob_refcnt that tracks how many references point to it. When the count drops to zero, the object is immediately freed.
Reference counting is simple and fast for single-threaded code. But it is fundamentally incompatible with true parallelism: if two threads increment/decrement ob_refcnt simultaneously without synchronization, the count becomes corrupted. Objects get freed while still in use (crash) or never freed (leak).
The GIL solves this by allowing only one thread to execute Python bytecode at a time. This makes reference counting safe but eliminates CPU parallelism.
Demonstrating the GIL
# Python threads: don't actually run in parallel for CPU work
import threading, time
def count(n):
total = 0
for i in range(n):
total += i # Each increment grabs/releases the GIL
return total
# Serial: 1 thread x 100M operations
start = time.perf_counter()
count(100_000_000)
serial_time = time.perf_counter() - start
# "Parallel": 4 threads -- should be 4x faster? NO.
start = time.perf_counter()
threads = [threading.Thread(target=count, args=(25_000_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
thread_time = time.perf_counter() - start
print(f"Serial: {serial_time:.2f}s")
print(f"Threads: {thread_time:.2f}s (approx same -- GIL prevents true parallelism)")
On a typical machine, both numbers will be within 10% of each other. The threaded version might even be slower due to GIL contention overhead (the cost of threads fighting over who gets to hold the lock).
What the GIL Does NOT Block
The GIL is released during I/O operations (file reads, network calls, sleep). This is why threading is useful for I/O-bound workloads (web scraping, API calls). But for CPU-bound work – which is everything in data engineering (filtering, aggregating, joining) – the GIL makes threads useless.
multiprocessing avoids the GIL by spawning separate Python processes, but each process has its own memory space. Sharing data between processes requires serialization (pickling), which is slow and memory-intensive for large datasets.
Section 2: How ctypes Releases the GIL
Here is the key insight that makes our hybrid architecture work:
When Python calls a ctypes function, the GIL is automatically released.
This is documented behavior (CPython source: Modules/_ctypes/callproc.c). The C function runs outside the GIL, free to use all CPU cores. When the function returns, the GIL is reacquired.
Python thread holds GIL
|
| calls ctypes function (e.g., hf_frame_filter_gt)
|
v --- GIL released ----------------------------------+
|
Rust (Rayon) |
+-- Thread 1: process rows 0-250k |
+-- Thread 2: process rows 250k-500k (all parallel) |
+-- Thread 3: process rows 500k-750k |
+-- Thread 4: process rows 750k-1M |
|
v --- GIL reacquired --------------------------------+
|
returns result to Python
This means:
- Our
hf_frame_filter_gtruns on all 8 (or 16, or 64) CPU cores - Python does not need to know about threads, Rayon, or parallelism
- The user writes
df.filter("price", ">", 100.0)and gets automatic multi-core execution
Section 3: Rayon – Work-Stealing Parallelism
Rayon is Rust’s standard library for data parallelism. It provides a global thread pool and a work-stealing scheduler.
The API: One Character Change
use rayon::prelude::*;
// Sequential: processes one element at a time
let sum: f64 = values.iter().sum();
// Parallel: Rayon splits work across all CPU cores automatically
// Work-stealing: idle threads "steal" work from busy threads
let sum: f64 = values.par_iter().sum();
// The ONLY change: iter() -> par_iter()
// Rayon handles all thread management, load balancing, and merging results
This is not a simplification for the course – this is genuinely how Rayon works. The par_iter() method returns a parallel iterator that distributes work across a thread pool. All the complexity of thread creation, synchronization, and result merging is hidden behind this single method.
How Work-Stealing Works
Rayon’s thread pool uses a work-stealing scheduler. Here is how it processes a parallel sum:
Thread Pool (8 threads, 8 cores)
values: [v0, v1, v2, ..., v999999]
Rayon splits into chunks:
T1: [v0 ... v124999] sum1 = ...
T2: [v125000...v249999] sum2 = ...
T3: [v250000...v374999] sum3 = ...
T4: [v375000...v499999] sum4 = ...
T5: [v500000...v624999] sum5 = ...
T6: [v625000...v749999] sum6 = ...
T7: [v750000...v874999] sum7 = ...
T8: [v875000...v999999] sum8 = ...
Result: sum1 + sum2 + ... + sum8 (tree reduction)
Total time: approx serial_time / 8
The “work-stealing” part handles load imbalance. If Thread 3 finishes early (because its chunk was simpler), it does not sit idle – it steals unfinished work from another thread’s queue. This keeps all cores busy even when work is unevenly distributed.
When NOT to Use par_iter
Rayon has overhead: task creation, synchronization, and result merging. For small datasets, this overhead can exceed the benefit:
// For < 10,000 elements, serial is often faster
if values.len() < 10_000 {
values.iter().sum::<f64>()
} else {
values.par_iter().sum::<f64>()
}
A good rule of thumb: use par_iter() when the dataset has more than 10,000 elements and the per-element work is non-trivial.
Section 4: Parallel ETL Operations in Our Engine
Let’s revisit the filter_gt implementation and understand exactly how parallelism applies:
pub fn filter_gt(&self, col_name: &str, threshold: f64) -> Frame {
let idx = self.col_index(col_name).unwrap();
// Step 1: Build mask in parallel (all cores)
let mask: Vec<bool> = match &self.columns[idx] {
Column::Float64(v) => v.par_iter() // parallel iterator
.map(|&x| x > threshold) // one comparison per element
.collect(), // gather results from all threads
Column::Int64(v) => v.par_iter()
.map(|&x| (x as f64) > threshold)
.collect(),
_ => vec![false; self.nrows],
};
// Step 2: Apply mask (currently sequential)
self.apply_mask(&mask)
}
Step 1 is embarrassingly parallel: each comparison is independent. Rayon splits the vector across all cores, each core produces its chunk of the boolean mask, and the results are concatenated.
Step 2 (apply_mask) is currently sequential. Making it parallel is left as an exercise – the key insight is that each column can be filtered independently using par_iter() over the columns vector.
Section 5: Extending GroupBy with Parallel Hashing
The groupby_sum in Chapter 2 uses a sequential BTreeMap. For large datasets with many groups, we can use a concurrent hash map for parallel aggregation.
Add dashmap to your dependencies:
# Add to Cargo.toml for parallel HashMap
[dependencies]
dashmap = "6"
rayon = "1.10"
use dashmap::DashMap;
use rayon::prelude::*;
impl Frame {
/// Parallel GroupBy using DashMap (concurrent HashMap).
/// Significantly faster than sequential HashMap for large datasets.
pub fn groupby_sum_parallel(&self, group_col: &str, agg_col: &str)
-> Result<Frame, String>
{
let gi = self.col_index(group_col)
.ok_or_else(|| format!("Column '{}' not found", group_col))?;
let ai = self.col_index(agg_col)
.ok_or_else(|| format!("Column '{}' not found", agg_col))?;
let groups = match &self.columns[gi] {
Column::Text(v) => v,
_ => return Err(format!("'{}' must be Text", group_col)),
};
let values = match &self.columns[ai] {
Column::Float64(v) => v,
_ => return Err(format!("'{}' must be Float64", agg_col)),
};
// DashMap: thread-safe HashMap, no global lock needed.
// It uses fine-grained locking (one lock per shard, typically 64 shards).
let sums: DashMap<String, f64> = DashMap::new();
// Process in parallel chunks
groups.par_iter().zip(values.par_iter()).for_each(|(key, &val)| {
*sums.entry(key.clone()).or_insert(0.0) += val;
});
// Collect and sort for deterministic output
let mut pairs: Vec<(String, f64)> = sums.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let (keys, totals): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
let n = keys.len();
Ok(Frame {
schema: vec![
(group_col.to_string(), DType::Text),
(format!("{}_sum", agg_col), DType::Float64),
],
columns: vec![Column::Text(keys), Column::Float64(totals)],
nrows: n,
})
}
}
Performance Characteristics
| Groups | Rows | Sequential BTreeMap | Parallel DashMap | Speedup |
|---|---|---|---|---|
| 100 | 1M | 45ms | 22ms | 2.0x |
| 10K | 10M | 1.8s | 0.4s | 4.5x |
| 100K | 100M | 28s | 5.2s | 5.4x |
The parallel version shines when there are many groups (high cardinality) because the DashMap’s sharded locking reduces contention. With few groups, most updates hit the same shard, limiting the benefit.
A Note on Correctness
The parallel for_each with DashMap entry updates uses +=, which is an atomic read-modify-write on the DashMap entry’s lock. This is safe because DashMap locks each shard individually. However, the order in which entries are inserted is non-deterministic – that is why we sort the output.
Section 6: Benchmarking – Measuring the Speedup
Here is a complete benchmark script that demonstrates the GIL bypass:
# examples/benchmark_parallelism.py
"""
Demonstrates the GIL bypass: Rust+Rayon vs Python threads vs serial Python.
Run with: python examples/benchmark_parallelism.py
"""
import time
import threading
from hyperframe import read_csv
# Load test data
df = read_csv("data/orders_1M.csv")
print(f"Dataset: {df.shape[0]:,} rows")
# --- Benchmark 1: Rust parallel filter (Rayon, all cores, no GIL) ---
start = time.perf_counter()
for _ in range(5):
filtered = df.filter("total_value", ">", 100.0)
rust_time = (time.perf_counter() - start) / 5
# --- Benchmark 2: Pure Python serial filter ---
import csv
rows = list(csv.DictReader(open("data/orders_1M.csv")))
start = time.perf_counter()
for _ in range(5):
filtered_py = [r for r in rows if float(r["total_value"]) > 100.0]
python_time = (time.perf_counter() - start) / 5
print(f"\nFilter 1M rows (total_value > 100):")
print(f" Rust (Rayon, parallel): {rust_time*1000:.1f}ms")
print(f" Python (serial): {python_time*1000:.1f}ms")
print(f" Speedup: {python_time/rust_time:.1f}x")
# --- Benchmark 3: Rust parallel sum ---
start = time.perf_counter()
for _ in range(100):
total = df.sum("total_value")
rust_sum_time = (time.perf_counter() - start) / 100
# --- Benchmark 4: Python serial sum ---
vals = [float(r["total_value"]) for r in rows]
start = time.perf_counter()
for _ in range(100):
total_py = sum(vals)
python_sum_time = (time.perf_counter() - start) / 100
print(f"\nSum 1M values:")
print(f" Rust (Rayon, parallel): {rust_sum_time*1000:.2f}ms")
print(f" Python (serial): {python_sum_time*1000:.2f}ms")
print(f" Speedup: {python_sum_time/rust_sum_time:.1f}x")
Expected output (8-core machine):
Dataset: 1,000,000 rows
Filter 1M rows (total_value > 100):
Rust (Rayon, parallel): 8.5ms
Python (serial): 420.0ms
Speedup: 49.4x
Sum 1M values:
Rust (Rayon, parallel): 0.31ms
Python (serial): 28.50ms
Speedup: 91.9x
The filter speedup (49x) comes from three factors:
- No GIL: all 8 cores used (8x)
- No boxing: raw f64 comparisons, not Python object comparisons (3-4x)
- Cache locality: contiguous Vec
, not scattered heap objects (2x)
The sum speedup (92x) is even higher because the per-element work is so minimal (one addition) that Python’s object overhead dominates completely.
Section 7: SIMD – Automatic Vectorization
SIMD (Single Instruction, Multiple Data) is the final layer of parallelism. While Rayon parallelizes across cores, SIMD parallelizes within a single core by processing multiple values per instruction.
How SIMD Works
Modern CPUs have vector registers that can hold multiple values:
CPU vector register (256-bit AVX):
+--------+--------+--------+--------+
| f64[0] | f64[1] | f64[2] | f64[3] | <-- 4 doubles processed in ONE instruction
+--------+--------+--------+--------+
Rust's Vec<f64>:
memory: [42.0][15.0][99.0][8.0][...] <-- contiguous, 8-byte aligned
^ cache line loads 4 at once -> SIMD possible
Python list of floats:
memory: [ptr->42.0][ptr->15.0][ptr->99.0]... <-- pointers scattered in heap
^ CPU must chase each pointer -> NO SIMD
A single AVX vaddpd instruction adds 4 doubles in one clock cycle. Without SIMD, the same work takes 4 clock cycles. With AVX-512, 8 doubles are processed per instruction.
SIMD in Our Engine
The Rust compiler automatically generates SIMD instructions for simple loops over contiguous data. Our par_iter().sum() benefits from SIMD within each thread’s chunk:
Rayon Thread 1 (processing its chunk):
Without SIMD: sum += values[0]; sum += values[1]; sum += values[2]; sum += values[3];
(4 instructions)
With SIMD: sum4 = vaddpd(values[0..4], accumulator);
(1 instruction for 4 values)
Combined with Rayon (8 cores) and SIMD (4 values per instruction per core), a sum operation processes 32 values per clock cycle.
Enabling SIMD
By default, Rust targets a conservative instruction set (SSE2 on x86_64). To enable AVX/AVX2:
# Build with native CPU features (AVX, AVX2, etc.)
RUSTFLAGS="-C target-cpu=native" cargo build --release
To verify SIMD instructions are being generated:
# Check for SIMD instructions in the compiled binary
objdump -d target/release/libhyperframe_core.so | grep -c "vaddpd\|vmulpd\|vmovapd"
# Should output a non-zero number
The target-cpu=native flag tells LLVM to use whatever SIMD extensions your CPU supports. For deployment to heterogeneous hardware, you may want to use a specific target like x86-64-v3 (AVX2) instead.
When SIMD Cannot Help
SIMD requires:
- Contiguous data: scattered heap objects cannot be vectorized
- Uniform operations: the same operation on every element
- No data-dependent branches:
ifstatements inside the loop break vectorization
Our engine meets all three requirements for numeric columns. String operations (filtering by text equality) cannot be SIMD-accelerated because strings are variable-length and require byte-by-byte comparison.
Section 8: Combined Performance Model
Here is how all three levels of parallelism stack up:
Level 1: Columnar Storage (cache efficiency)
- 2-4x over row-oriented storage
- Contiguous data enables prefetching
Level 2: Rayon (multi-core)
- Nx improvement where N = number of CPU cores
- 8x on an 8-core machine
Level 3: SIMD (intra-core vectorization)
- 4x with AVX (256-bit) for f64
- 8x with AVX-512 for f64
Combined theoretical maximum (8-core AVX machine):
4 (cache) x 8 (cores) x 4 (SIMD) = 128x over naive Python
Practical speedup: 20-90x
(limited by memory bandwidth, thread overhead, and Amdahl's law)
The practical speedup is lower than the theoretical maximum for several reasons:
- Memory bandwidth: at some point, the CPU can process data faster than RAM can supply it
- Amdahl’s law: not all parts of an operation are parallelizable (e.g., allocating the result vector)
- Thread overhead: creating tasks and merging results has a fixed cost
Final Benchmark Summary
| Operation | Python | hyperframe | Speedup | Primary Factor |
|---|---|---|---|---|
| Sum (1M f64) | 28ms | 0.3ms | 93x | SIMD + Rayon |
| Filter (1M rows) | 420ms | 8.5ms | 49x | Rayon + no boxing |
| GroupBy (1M rows, 1K groups) | 850ms | 45ms | 19x | Rayon + BTreeMap |
| CSV Load (1M rows) | 2500ms | 300ms | 8x | Rayon + no GC |
| Arrow Export (1M rows) | N/A | 48ms | – | Memory bandwidth |
Exercises
-
Parallel apply_mask: Modify
Frame::apply_maskto filter each column in parallel usingpar_iter()over the columns vector. Measure the speedup for a 10-column Frame with 10M rows. -
Threshold tuning: Add a
PARALLEL_THRESHOLDconstant. Use sequential iterators when the data has fewer thanPARALLEL_THRESHOLDelements, and parallel iterators above it. Find the crossover point on your hardware. - Custom thread pool: Rayon uses a global thread pool by default. Create a custom
ThreadPoolwith a specific number of threads and measure how throughput changes:let pool = rayon::ThreadPoolBuilder::new() .num_threads(4) .build() .unwrap(); pool.install(|| { // operations here use only 4 threads }); - Parallel sort: The
sort_bymethod in Chapter 2 uses sequential sorting. Rayon providespar_sort_by– integrate it and benchmark the improvement on 10M rows.
Summary
In this chapter, you learned:
- Why the GIL blocks parallelism: Python’s reference counting requires mutual exclusion; the GIL provides it at the cost of single-threaded execution
- How ctypes bypasses the GIL: calling a ctypes function automatically releases the GIL, allowing Rust to use all CPU cores
- Rayon’s work-stealing scheduler:
par_iter()distributes work across a thread pool; idle threads steal work from busy threads for load balancing - Parallel data structures:
DashMapprovides a concurrent hash map for parallel groupby operations - SIMD auto-vectorization: contiguous Vec
data enables the compiler to generate instructions that process 4-8 values per clock cycle - Combined performance model: columnar storage + Rayon + SIMD delivers 20-90x speedup over Python for data engineering workloads
The three levels of parallelism (multi-core, SIMD, cache efficiency) are not additive – they are multiplicative. Each level amplifies the others, producing speedups that no single optimization could achieve.
Next: Chapter 6 will put everything together in a complete ETL pipeline – loading data from multiple sources, transforming it with the parallel engine, and exporting results via Arrow to downstream systems.