Question
How to Reach Peak Floating-Point Throughput on x86-64 Intel CPUs in C/C++
Question
How can the theoretical peak performance of 4 double-precision floating-point operations per cycle be achieved on a modern x86-64 Intel CPU?
My understanding is:
- On many Intel CPUs, an SSE floating-point add has a latency of about 3 cycles.
- A floating-point multiply has a latency of about 5 cycles.
- Because of pipelining, if enough independent operations exist, throughput can still approach one add per cycle and one multiply per cycle.
- Since packed SSE instructions such as
addpdandmulpdoperate on two doubles at once, that suggests a theoretical throughput of:- 2 FLOPs/cycle for packed adds alone
- 2 FLOPs/cycle for packed multiplies alone
- potentially 4 FLOPs/cycle if adds and multiplies can execute in parallel
However, I have not been able to reproduce that in a simple C/C++ program. My best result is around 2.7 FLOPs/cycle.
Here is the test program I used:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/time.h>
double stoptime(void) {
struct timeval t;
gettimeofday(&t, NULL);
return (double)t.tv_sec + t.tv_usec / 1000000.0;
}
double addmul(double add, double mul, int ops) {
// Initialize differently so the compiler cannot trivially fold everything away.
double sum1 = 0.1, sum2 = -0.1, sum3 = 0.2, sum4 = -0.2, sum5 = 0.0;
double mul1 = 1.0, mul2 = 1.1, mul3 = 1.2, mul4 = 1.3, mul5 = 1.4;
int loops = ops / 10; // 10 floating-point operations per loop iteration
double expected = 5.0 * add * loops
+ (sum1 + sum2 + sum3 + sum4 + sum5)
+ pow(mul, loops) * (mul1 + mul2 + mul3 + mul4 + mul5);
for ( i = ; i < loops; i++) {
mul1 *= mul; mul2 *= mul; mul3 *= mul; mul4 *= mul; mul5 *= mul;
sum1 += add; sum2 += add; sum3 += add; sum4 += add; sum5 += add;
}
sum1 + sum2 + sum3 + sum4 + sum5
+ mul1 + mul2 + mul3 + mul4 + mul5 - expected;
}
{
(argc != ) {
(, argv[]);
();
(EXIT_FAILURE);
}
n = (argv[]) * ;
(n <= )
n = ;
x = M_PI;
y = + ;
t = ();
x = (x, y, n);
t = () - t;
(, t, ()n / t / , x);
EXIT_SUCCESS;
}
Compiled with:
g++ -O2 -march=native addmul.cpp
./a.out 1000
Example result on an Intel Core i5-750 at 2.66 GHz:
addmul: 0.270 s, 3.707 Gflops, res=1.326463
That is about 1.4 FLOPs per cycle.
The generated loop looks roughly like this:
.L4:
inc eax
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
mulsd xmm5, xmm3
mulsd xmm1, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
addsd xmm10, xmm2
addsd xmm9, xmm2
cmp eax, ebx
jne .L4
If the scalar operations were replaced by packed instructions like addpd and mulpd, the FLOP count would double without doubling execution time, which suggests almost 2.8 FLOPs/cycle. Is there a simple example in C/C++ or assembly that gets close to the theoretical 4 FLOPs/cycle?
Short Answer
By the end of this page, you will understand why theoretical FLOPs-per-cycle numbers are difficult to achieve in practice, how latency, throughput, SIMD width, instruction scheduling, and loop unrolling interact, and what kind of benchmark is actually needed to approach peak floating-point throughput on Intel x86-64 CPUs.
Concept
Peak floating-point performance is not just about how fast a single instruction finishes. It depends on several lower-level CPU concepts working together.
The key ideas
1. Latency is not the same as throughput
- Latency = how many cycles one instruction takes to finish.
- Throughput = how often the CPU can start a new instruction of that type.
For example, a floating-point multiply may take 5 cycles of latency, but the CPU may still start one multiply every cycle if there are enough independent multiply chains.
2. Independent operations are required
This works:
a *= x;
b *= x;
c *= x;
d *= x;
because each variable is independent.
This does not allow full throughput:
a *= x;
a *= x;
a *= x;
a *= x;
because each operation depends on the previous result.
3. SIMD increases work per instruction
Scalar SSE instructions like addsd operate on one double.
Packed SSE instructions like addpd operate on two doubles at once.
So if the CPU can issue one packed add per cycle, that is already 2 double-precision FLOPs per cycle.
4. Different execution ports matter
Many Intel CPUs have separate execution resources for floating-point add and multiply. In the best case, the CPU can issue:
Mental Model
Think of the CPU as a small factory with multiple workstations.
- One station handles adds.
- One station handles multiplies.
- Each station can start a new job every cycle.
- But each job takes a few cycles to finish.
If you only give the factory one item at a time, workers sit idle waiting for the previous item to move forward.
If you give the factory many independent items, all stations stay busy.
Now add SIMD:
- A scalar instruction is like moving one box.
- A packed SSE instruction is like moving two boxes at once.
So the maximum output comes from:
- feeding both stations continuously
- making sure jobs are independent
- packing more data into each instruction
- reducing management overhead like branching and loop counters
That is why peak FLOPs/cycle is less about one fast instruction and more about keeping the whole pipeline busy.
Syntax and Examples
Scalar vs packed SSE idea
At a high level, these operations differ like this:
Scalar style
double a = 1.0, b = 2.0;
a += b;
a *= b;
This works on one double at a time.
Packed style with intrinsics
#include <immintrin.h>
__m128d a = _mm_set_pd(2.0, 1.0);
__m128d b = _mm_set1_pd(3.0);
a = _mm_add_pd(a, b);
a = _mm_mul_pd(a, b);
This works on two doubles at once using SSE2.
A simple throughput-oriented example
The following example creates several independent SIMD chains so the CPU can overlap work:
#include <immintrin.h>
#include <stdio.h>
int main() {
__m128d addv = _mm_set1_pd(1.0);
__m128d mulv = _mm_set1_pd(1.0000001);
__m128d s0 = _mm_set_pd(1.0, 2.0);
__m128d s1 = _mm_set_pd(3.0, 4.0);
__m128d s2 = _mm_set_pd(, );
__m128d s3 = _mm_set_pd(, );
( i = ; i < ; i++) {
s0 = _mm_add_pd(s0, addv);
s1 = _mm_mul_pd(s1, mulv);
s2 = _mm_add_pd(s2, addv);
s3 = _mm_mul_pd(s3, mulv);
}
out[];
_mm_storeu_pd(out, s0);
(, out[], out[]);
;
}
Step by Step Execution
Consider this smaller example:
#include <immintrin.h>
void demo() {
__m128d addv = _mm_set1_pd(1.0);
__m128d mulv = _mm_set1_pd(2.0);
__m128d a0 = _mm_set1_pd(10.0);
__m128d a1 = _mm_set1_pd(20.0);
__m128d m0 = _mm_set1_pd(3.0);
__m128d m1 = _mm_set1_pd(4.0);
for (int i = 0; i < 2; i++) {
a0 = _mm_add_pd(a0, addv);
a1 = _mm_add_pd(a1, addv);
m0 = _mm_mul_pd(m0, mulv);
m1 = _mm_mul_pd(m1, mulv);
}
}
Before the loop
a0 = [10.0, 10.0]a1 = [20.0, 20.0]m0 = [3.0, 3.0]m1 = [4.0, 4.0]addv = [1.0, 1.0]mulv = [2.0, 2.0]
First iteration
a0 = a0 + addv; // [11.0, 11.0]
a1 = a1 + addv; // [21.0, 21.0]
m0 = m0 * mulv;
m1 = m1 * mulv;
Real World Use Cases
Even though this question is about a microbenchmark, the underlying idea shows up in real software.
Numerical computing
Scientific code often tries to keep arithmetic units busy in kernels such as:
- vector updates
- matrix multiplication
- polynomial evaluation
- simulation loops
These kernels are often manually unrolled or vectorized.
DSP and signal processing
Operations like filtering and transforms often use repeated multiply/add patterns. Throughput matters because the same small loop runs millions of times.
Graphics and physics
Game engines and physics solvers perform large numbers of floating-point operations on vectors. SIMD and instruction scheduling can matter a lot in hot loops.
ML and linear algebra
Although modern workloads often use AVX, AVX2, AVX-512, or GPUs, the same principle applies: peak performance depends on feeding vector units efficiently.
Benchmarking and performance engineering
When teams evaluate CPUs or compilers, they often write small kernels like this to measure:
- raw arithmetic throughput
- compiler vectorization quality
- instruction scheduling behavior
- differences across microarchitectures
Real Codebase Usage
In real projects, developers rarely write a benchmark whose only purpose is to maximize FLOPs. But they do use the same patterns inside performance-critical code.
Common patterns
Multiple accumulators
Instead of one running total:
double sum = 0.0;
for (int i = 0; i < n; i++) {
sum += data[i];
}
developers may use several accumulators to reduce dependency chains:
double s0 = 0.0, s1 = 0.0, s2 = 0.0, s3 = 0.0;
for (int i = 0; i < n; i += 4) {
s0 += data[i];
s1 += data[i + 1];
s2 += data[i + 2];
s3 += data[i + 3];
}
double sum = s0 + s1 + s2 + s3;
This helps expose instruction-level parallelism.
Loop unrolling
Compilers or developers unroll loops to:
- reduce branch overhead
- expose more independent instructions
- improve scheduling opportunities
SIMD intrinsics
For critical kernels, codebases often use intrinsics such as:
_mm_add_pd
Common Mistakes
1. Confusing latency with throughput
Beginners often think a 5-cycle multiply means only one multiply every 5 cycles.
That is incorrect on a pipelined CPU.
- Latency: result ready after 5 cycles
- Throughput: a new multiply may still start every cycle
2. Using dependent operations only
Broken benchmark pattern:
double x = 1.0;
for (int i = 0; i < n; i++) {
x *= c;
}
Why it is bad:
- every iteration depends on the previous result
- the CPU cannot overlap many multiplies
Better:
double x0 = 1.0, x1 = 1.1, x2 = 1.2, x3 = 1.3;
for (int i = 0; i < n; i++) {
x0 *= c;
x1 *= c;
x2 *= c;
x3 *= c;
}
3. Measuring scalar code but expecting SIMD peak
If you use addsd and mulsd, you are only operating on one double per instruction. You cannot expect packed-SSE peak from scalar instructions.
4. Forgetting loop overhead
Very small loops pay for:
Comparisons
| Concept | What it means | Good for peak FLOP benchmarking? | Why |
|---|---|---|---|
| Latency | Time for one instruction result to become ready | No, by itself | Peak throughput depends on overlapping many instructions |
| Throughput | How often a new instruction can start | Yes | This is the key metric for peak issue rate |
Scalar SSE (addsd, mulsd) | One double per instruction | Limited | Can only do 1 double per instruction |
Packed SSE (addpd, mulpd) | Two doubles per instruction | Better | Doubles arithmetic work per instruction |
| Dependent chain | Each instruction uses previous result |
Cheat Sheet
Core rules
- Latency = cycles until result is ready
- Throughput = new instructions started per cycle
- Peak FLOPs require high throughput, not just low latency
- Use independent accumulators to hide latency
- Use SIMD packed instructions to process multiple values per instruction
- Use loop unrolling to reduce branch overhead and improve scheduling
SSE2 double-precision basics
addsd= scalar add of 1 doublemulsd= scalar multiply of 1 doubleaddpd= packed add of 2 doublesmulpd= packed multiply of 2 doubles
Theoretical SSE2 peak idea
If a CPU can issue per cycle:
- 1 packed add = 2 FLOPs
- 1 packed multiply = 2 FLOPs
Then peak is:
- 4 double-precision FLOPs/cycle
What you need to get close
- enough independent add chains
- enough independent multiply chains
- packed SIMD instructions
- good compiler scheduling or manual tuning
- minimal memory traffic
- enough loop iterations for stable timing
Common benchmark pattern
for (...) {
a0 = _mm_add_pd(a0, addv);
a1 = _mm_add_pd(a1, addv);
m0 = _mm_mul_pd(m0, mulv);
m1 = _mm_mul_pd(m1, mulv);
}
FAQ
Why does a 5-cycle multiply not limit me to one multiply every 5 cycles?
Because modern CPUs are pipelined. A multiply can take 5 cycles to finish, but the CPU may still start a new multiply every cycle if the operations are independent.
Why doesn't my scalar C++ loop reach the theoretical peak?
Because scalar instructions process only one double per instruction, and the compiler may not schedule operations ideally. Peak numbers usually assume packed SIMD instructions and carefully structured loops.
Do I need assembly to reach peak performance?
Not always. Intrinsics and a good compiler can get close. But for a strict microbenchmark of theoretical peak, hand-written assembly gives the most control.
Why do independent accumulators help so much?
They break dependency chains. That lets the CPU work on several operations in flight at once instead of waiting for one chain to finish.
Is 4 FLOPs per cycle always possible on Intel x86-64?
No. It depends on the exact microarchitecture, the instruction set in use, and whether the code can keep the relevant execution units busy.
Why does loop unrolling help floating-point throughput?
It reduces branch overhead and exposes more instructions to the CPU scheduler, which improves instruction-level parallelism.
Is packed SSE enough on modern CPUs?
For older or SSE2-focused discussions, yes. But many modern CPUs support AVX and AVX2, which process even more doubles per instruction and raise the peak further.
Why can compiler choice change the result so much?
Different compilers make different decisions about vectorization, instruction ordering, register allocation, and loop unrolling.
Mini Project
Description
Build a small floating-point throughput benchmark in C++ using SSE2 intrinsics. The goal is not to solve a business problem, but to learn how benchmark structure affects CPU performance. You will compare a dependent scalar-style pattern with a SIMD-friendly pattern that uses multiple independent accumulators.
Goal
Create a benchmark that demonstrates how SIMD and independent operation chains improve floating-point throughput on an Intel x86-64 CPU.
Requirements
- Write one loop with a dependency-heavy computation.
- Write a second loop using SSE2 packed doubles with multiple independent accumulators.
- Time both loops over many iterations.
- Prevent the compiler from removing the computed results.
- Print the elapsed time and a simple throughput estimate.
Keep learning
Related questions
Advantages of Brace Initialization in C++
Learn why C++ brace initialization is often clearer and safer than other object initialization styles, with examples and common pitfalls.
Basic Rules and Idioms for Operator Overloading in C++
Learn the core rules, syntax, and common idioms for operator overloading in C++, including member vs non-member operators.
C++ Aggregates, Trivial Types, Trivially Copyable Types, and PODs Explained
Learn what aggregates, trivial types, trivially copyable types, and PODs mean in C++, how they differ, and why they matter.