Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

Beating PyTorch CPU: 8.7x Speedup in C++ Inference with FP16 Batch Interleaving

How we achieved 312 s/s throughput on MiniLM-L6-v2 with hand-tuned AVX2 FP16 GEMM kernels — beating PyTorch CPU batch-1 by 1.41x while maintaining 0.999999 cosine accuracy.

The Starting Point

We set out to build a pure C++ inference engine for sentence-transformers/all-MiniLM-L6-v2 — a 22M parameter model used for semantic embeddings. No PyTorch dependency, no BLAS library, no framework overhead. Just hand-tuned AVX2 SIMD kernels and a custom model format (.vnn).

The initial unoptimized implementation ran at 35.87 samples/s on an Intel i7-11800H. PyTorch on the same CPU with batch=1 managed 221 s/s. We were 6x slower than the baseline we wanted to beat.

35.87
Baseline s/s
311.75
Current s/s
8.7x
Cumulative Speedup
0.999999
Cosine Accuracy

Optimization Journey

1

Hashmap to Sorted Array Tokenizer

Replaced std::unordered_map with sorted array + binary search for vocab lookup. Modest gain but eliminated hash collision overhead.

2

AVX2 Vectorized Attention

Tiled Q×K^T and softmax with 8-wide SIMD. Attention dropped from 27ms to 20ms per 100 sentences.

3

Fused FFN1+GELU Kernel

Eliminated separate GELU pass by fusing activation into the GEMM output loop. Fast AVX2 exp/sigmoid for vectorized GELU — 2.7x FFN speedup.

4

FP16 Weight-Only Quantization

Stored FFN weights as FP16 (uint16_t), converted on-the-fly with F16C intrinsics (_mm256_cvtph_ps). Halved weight memory bandwidth. Accuracy preserved: 0.999999 cosine.

5

2x Batch-Interleaved GEMM

The key innovation: process 2 input vectors simultaneously, sharing FP16→FP32 weight conversions. 2:1 FMA-to-conversion ratio amortizes the F16C bottleneck. FFN dropped from 136ms to 112ms.

The Batch Interleaving Insight

The F16C instruction _mm256_cvtph_ps converts 8 FP16 values to FP32 in one instruction — but it can only execute at 1 per clock. Fused multiply-add (_mm256_fmadd_ps) runs at 2 per clock. This means FP16 GEMM is conversion-bound, not compute-bound.

Batch interleaving solves this by processing 2 input vectors per weight row. Each weight conversion feeds 2 FMAs — one for each input. This doubles the FMA-to-conversion ratio from 1:1 to 2:1, effectively hiding the conversion latency behind useful computation.

// 4 output rows × 2 batch items = 8 accumulators (2KB registers — safe)for (d = 0; d < in_dim; d += 8) { __m256 w = load_f16_as_f32(weight + d); // 1 conversion acc0 = _mm256_fmadd_ps(vx0, w, acc0); // FMA for batch 0 acc1 = _mm256_fmadd_ps(vx1, w, acc1); // FMA for batch 1 — FREE!// 2 FMAs per 1 conversion = 2:1 ratio }

The 2:1 FMA-to-conversion ratio is the key innovation. Don't sacrifice it for anything.

The Preconversion Failure

We hypothesized that separating conversion from FMA would improve instruction-level parallelism. The idea: pre-convert FP16 weights to FP32 in L1-resident stack buffers, then run pure FP32 FMA without conversion bottleneck.

L1 Preconversion Kernel

255 s/s — 16.8% SLOWER than baseline. Pre-converting loses the batch interleaving benefit (1:1 ratio). Stack buffer overhead adds latency. The 2:1 sharing was the entire point.

Vectorized GELU

Fast exp_ps was 17% slower than scalar expf for GELU. GELU is <1% of FFN time — sometimes scalar is faster than vectorized for non-bottleneck operations.

Fused FFN Pipeline

Analysis showed the intermediate buffer between FFN1 and FFN2 is only 0.63ms (0.5% of FFN). Fusion would add complexity for negligible gain. Not worth it.

The CPU Governor Trap

Critical discovery: During benchmarking, we noticed a 30-40% throughput regression across sessions. The root cause was the Linux CPU governor set to powersave, causing the i7-11800H to run at 1.9-4.0 GHz inconsistently instead of sustained 3.6+ GHz. Setting performance governor recovered throughput from 245 to 312 s/s. Always check /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor before benchmarking.

Head-to-Head: VNN vs PyTorch

100 sentences, 5 iterations, performance governor, i7-11800H + RTX 3060:

RuntimeDeviceBatchThroughput (s/s)Memory (MB)vs VNN
VNN C++ (release)CPU100311.75151.35—
PyTorch 5.6.0CPU1221.3018.630.71x
PyTorch 5.6.0CPU8537.2722.411.72x
PyTorch 5.6.0CUDA1376.6695.271.21x
PyTorch 5.6.0CUDA81,200.83187.403.85x

VNN beats PyTorch CPU batch-1 by 1.41x — our hand-tuned FP16+SIMD kernel wins on single-sentence inference where PyTorch's framework overhead dominates. But PyTorch CPU batch-8 beats VNN by 1.72x, because BLAS libraries have highly optimized batch GEMM that amortizes weight loads across multiple inputs.

Per-Stage Breakdown

StageTime (ms)% of InferenceOptimization Status
FFN (GEMM + GELU)112.4952.3%FP16 + batch-interleaved
QKV GEMM48.1122.3%Still FP32 — next target
Attention20.299.4%AVX2 vectorized
Tokenize1.040.5%Sorted array + binary search
Embed lookup0.740.3%Direct index
Pool + L2 norm0.490.2%AVX2 horizontal sum

What's Next

QKV FP16 Conversion

22.3% of inference, still using FP32 weights. Converting to FP16 with batch-interleaved kernels should yield 3-5% throughput gain. 5MB memory savings across 6 layers.

Batch GEMM

The biggest opportunity: process 8 sentences simultaneously through GEMM layers. Could close the 1.72x gap with PyTorch CPU b8. Requires input layout refactor.

Benchmark Environment Guard

Auto-detect CPU governor and warn if powersave. Record environment metadata in telemetry. Prevents false regression alarms.

Lessons Learned

Benchmarking environment matters as much as kernel optimization. The best kernel in the world won't help if your CPU is throttling to 1.9 GHz.

← All transmissions