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 (int i = 0; i < loops; i++) {
mul1 *= mul; mul2 *= mul; mul3 *= mul; mul4 *= mul; mul5 *= mul;
sum1 += add; sum2 += add; sum3 += add; sum4 += add; sum5 += add;
}
return sum1 + sum2 + sum3 + sum4 + sum5
+ mul1 + mul2 + mul3 + mul4 + mul5 - expected;
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("usage: %s <num>\n", argv[0]);
printf("number of operations: <num> millions\n");
exit(EXIT_FAILURE);
}
int n = atoi(argv[1]) * 1000000;
if (n <= 0)
n = 1000;
double x = M_PI;
double y = 1.0 + 1e-8;
double t = stoptime();
x = addmul(x, y, n);
t = stoptime() - t;
printf("addmul:\t %.3f s, %.3f Gflops, res=%f\n", t, (double)n / t / 1e9, x);
return 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:
- one packed add per cycle
- one packed multiply per cycle
With SSE packed doubles, that means:
addpd= 2 FLOPsmulpd= 2 FLOPs- total = 4 FLOPs/cycle
But that is only possible if:
- the CPU microarchitecture supports this issue pattern
- the compiler generates suitable code
- there are enough independent operations
- instruction ordering does not create bottlenecks
- loop overhead and branch overhead are small enough
5. Theoretical peak is a best-case microbenchmark
The advertised peak assumes an ideal loop that:
- stays entirely in registers
- has no memory bottleneck
- has no dependency stalls
- is heavily unrolled
- uses packed SIMD instructions
- is scheduled to match the CPU’s execution units
Your original code is close in spirit, but it uses scalar operations and relies on the compiler to schedule things ideally. That often falls short of hardware peak.
Why this matters in real programming
Understanding peak throughput helps with:
- writing realistic microbenchmarks
- understanding why vectorized code is much faster than scalar code
- interpreting profiler results correctly
- knowing when your code is CPU-bound vs memory-bound
- understanding why compilers sometimes reorder operations or unroll loops
In short: peak FLOPs/cycle is usually achieved only in carefully designed tight loops, not automatically in ordinary C/C++ source.
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(5.0, 6.0);
__m128d s3 = _mm_set_pd(7.0, 8.0);
for (int i = 0; i < 100000000; 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);
}
double out[2];
_mm_storeu_pd(out, s0);
printf("%f %f\n", out[0], out[1]);
return 0;
}
Why this is better than a single chain
This loop is better for throughput because:
s0,s1,s2, ands3are independent- packed instructions process 2 doubles each
- the CPU can overlap adds and multiplies more easily
Why it still may not hit peak
Even this may not reach theoretical peak because:
- the compiler may not schedule instructions ideally
- the loop may need more unrolling
- the target CPU may have architectural limits
- the benchmark still includes loop control overhead
A more realistic benchmark pattern
A better benchmark usually uses:
- more independent accumulators
- explicit unrolling
- packed SIMD intrinsics or hand-written assembly
- minimal loads and stores inside the loop
Example:
#include <immintrin.h>
void kernel(long long iters) {
__m128d addv = _mm_set1_pd(1.0);
__m128d mulv = _mm_set1_pd(1.0000001);
__m128d a0 = _mm_set1_pd(1.0);
__m128d a1 = _mm_set1_pd(2.0);
__m128d a2 = _mm_set1_pd(3.0);
__m128d a3 = _mm_set1_pd(4.0);
__m128d m0 = _mm_set1_pd(1.1);
__m128d m1 = _mm_set1_pd(1.2);
__m128d m2 = _mm_set1_pd(1.3);
__m128d m3 = _mm_set1_pd(1.4);
for (long long i = 0; i < iters; i++) {
a0 = _mm_add_pd(a0, addv);
a1 = _mm_add_pd(a1, addv);
a2 = _mm_add_pd(a2, addv);
a3 = _mm_add_pd(a3, addv);
m0 = _mm_mul_pd(m0, mulv);
m1 = _mm_mul_pd(m1, mulv);
m2 = _mm_mul_pd(m2, mulv);
m3 = _mm_mul_pd(m3, mulv);
}
volatile double sink[2];
_mm_storeu_pd((double*)sink, _mm_add_pd(a0, m0));
}
This structure gives the compiler more freedom to fill the pipeline.
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; // [6.0, 6.0]
m1 = m1 * mulv; // [8.0, 8.0]
- Two packed adds happened.
- Two packed multiplies happened.
- Each instruction processed 2 doubles.
So this iteration performs:
- 2 add instructions × 2 doubles = 4 FLOPs
- 2 mul instructions × 2 doubles = 4 FLOPs
- total = 8 FLOPs
Second iteration
a0 = [12.0, 12.0]
a1 = [22.0, 22.0]
m0 = [12.0, 12.0]
m1 = [16.0, 16.0]
Again, the CPU can overlap these because the chains are independent:
a0depends only on priora0a1depends only on priora1m0depends only on priorm0m1depends only on priorm1
Why independence matters
If all operations updated only one register, the next instruction would have to wait for the previous result. Multiple independent accumulators hide latency and increase throughput.
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_mm_mul_pd_mm256_add_pd_mm256_mul_pd
This gives more direct control than plain scalar C++.
Guarding against optimization removal
Benchmarks need to ensure the compiler cannot delete the work. Common techniques:
- store final results to memory
- return a value derived from the computation
- use
volatileonly carefully
Architecture-specific tuning
Real codebases often provide several implementations:
- scalar fallback
- SSE2 version
- AVX2 version
- AVX-512 version
and pick the best one at compile time or runtime.
Important practical note
Real applications are often limited by memory bandwidth, cache misses, or data layout, not just raw FLOP throughput. Microbenchmarks reveal CPU capability, but application performance depends on the whole system.
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:
- incrementing the counter
- comparing the counter
- branching back
Unrolling helps reduce this overhead.
5. Assuming the compiler will schedule perfectly
Compilers do a good job, but not always the ideal job for a specific microarchitecture. Your generated assembly may be correct but not optimal for peak throughput.
6. Ignoring microarchitecture differences
Different Intel generations behave differently. A schedule that works well on one CPU may be suboptimal on another.
7. Letting the compiler optimize away the benchmark
If the result is unused, the compiler may remove work.
Broken example:
for (int i = 0; i < n; i++) {
x += 1.0;
}
If x is never observed, this may disappear entirely.
8. Using a weak timing method
For very short benchmarks, coarse timers can distort results. Use a stable timing method and enough iterations to make the runtime significant.
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 | Poor | Creates stalls from data dependencies |
| Independent chains | Several separate accumulators | Excellent | Hides latency and feeds pipelines |
| Plain loop | Minimal source complexity | Sometimes | May not expose enough ILP |
| Unrolled loop | More work per iteration | Better | Reduces loop overhead and improves scheduling |
| Compiler auto-vectorization | Compiler decides SIMD use | Sometimes | Depends on pattern and compiler quality |
| Intrinsics | Explicit SIMD instructions | Often | Gives more control over generated code |
| Hand-written assembly | Full control | Best for microbenchmarks | Most precise, but least portable |
Scalar vs packed example
// Scalar: 1 double per instruction
x = x + a;
y = y * b;
// Packed SSE2: 2 doubles per instruction
vx = _mm_add_pd(vx, va);
vy = _mm_mul_pd(vy, vb);
Throughput benchmark vs real application
| Type | Goal |
|---|---|
| Microbenchmark | Measure best-case arithmetic throughput |
| Real application | Solve a useful problem with real data and memory access |
A loop can be ideal for peak FLOPs but still not represent real application performance.
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);
}
Warning signs
- only one accumulator
- scalar instructions only
- loop not unrolled
- result optimized away
- timing includes setup work
- expecting one CPU’s schedule to match another CPU’s behavior
Practical takeaway
To approach peak FLOPs/cycle on x86-64 Intel CPUs, think in this order:
- vectorize
- add independent chains
- unroll
- inspect assembly
- measure carefully
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.