01 / 07 WORK / PROJECT← All work

Algorithms · Performance · Python

More Cores ≠ Linear Speed: What I Learned Parallelizing in Python

A small, reproducible experiment about runtime, efficiency and the price of additional parallelism.

I wanted to understand why more cores do not automatically make a calculation proportionally faster. I parallelized a deterministic numerical integration of π in Python and measured runtime, efficiency, task size and memory use.

Project period: 09.2026 – 09.2026

Context

I wanted to test why doubling compute resources does not automatically halve runtime, using repeatable measurements and inspectable raw data.

Approach

I split the midpoint-rule calculation into independent ranges. Worker processes form partial sums, then the parent adds them. I compare end-to-end ProcessPool time against separate sequential references and observe peak RSS.

System

  • Midpoint integration for π: 4 / (1 + x²) over [0, 1].
  • Pure Python loop without NumPy; processes return partial sums.
  • ProcessPoolExecutor with 1, 2, 4, 6, 8 or 12 workers; five runs, median plus minimum and maximum.
  • Fixed N for strong scaling, calibrated sizes in a separate block; end-to-end wall time and peak RSS.
SYSTEM LAYERS
  • Numerical method
  • Worker processes
  • Task distribution
  • Reduction
  • Measurement analysis

Engineering decisions

  • Deterministic integration, not Monte Carlo.
  • Processes rather than threads for the CPU-bound Python loop; no thread comparison.
  • Separate sequential baselines; median and min/max across five repetitions.

What did not work

One CPU-bound algorithm on one system with its scheduler; no CPU affinity. RSS may double-count shared pages. P × T_P is not energy or CPU-time measurement; separate runs can vary.

Result

Strong-scaling Large: 4.056 s sequential, 0.572 s with twelve processes (7.09 speedup; about 59% efficiency). P × T_P rises from 4.06 to 6.87 processor-seconds. In the separate size block, peak RSS grows from roughly 70 to 299 MB.

What stayed with me

Parallelism speeds up the calculation, but speedup, efficiency and memory do not scale alike. Useful parallelism depends on whether saved time is worth the management, memory and added resources.

My contribution

The repository contains the benchmark code, the configuration used, raw data, summarized results and the generated figures. The measurements in this case study come from the full run on September 26, 2026.

The question behind the experiment

When two people divide a task, we often imagine they will finish about twice as fast. The same idea seems natural for processors: why not keep adding cores and share every calculation across more of them?

I wanted to know not just whether parallel code was faster, but what overhead remained between splitting the work and producing a result.

Parallel work still has to be combined

A sequential algorithm performs its steps one after another. A parallel algorithm assigns independent subtasks to multiple compute units. For a sum, for example, (a + b) and (c + d) can be calculated at the same time; the two partial results are added afterwards. That final combination is a reduction. It is simple, but it does not disappear just because the earlier work ran in parallel.

That is the first trade-off: some work can happen simultaneously, while other steps still depend on previous results or need coordination. Lecture material on parallel algorithms makes this visible with problems such as sums and prefix sums.[3] A sum worked well for my experiment because each worker could process a disjoint range and return one partial result.

What I measure

Runtime T tells me how long a calculation takes. Speedup compares direct sequential time T_seq with parallel time T_P: S = T_seq / T_P. If eight seconds become two, S = 4. The tested version completes the same work in a quarter of the wall-clock time.

Efficiency relates that speedup to P, the number of worker processes: E = S / P. It does not tell me how busy the CPUs were. It tells me how much speedup I received per process I used. Efficiency is therefore a scaling measure, not a CPU-utilization meter.

For parallel costs I use P × T_P. In this simple model, that expresses how many processor-seconds are associated with P concurrently provisioned compute units over runtime T_P. Management, scheduling, communication, result transfer and reduction are overhead. Memory is another measure: a run can finish sooner and still use more RAM.

Why I calculated π

I chose numerical integration, not a Monte Carlo method. The mathematical starting point is ∫₀¹ 4 / (1 + x²) dx = π: an antiderivative of 1 / (1 + x²) is arctan(x); from zero to one that gives π/4, and multiplying by four gives π.[1]

For the midpoint rule, I divide [0, 1] into N equal intervals, evaluate the function at each midpoint and multiply the sum by the interval width. The loop adds each contribution directly to a running sum instead of storing millions of values.

The intervals are independent. I can split N into contiguous ranges, calculate each range separately and add the partial sums at the end. The mathematical task stays the same; only the order and number of simultaneously executed steps change.

How the benchmark is set up

The benchmark is a pure Python loop without NumPy vectorization. Parallel variants use `concurrent.futures.ProcessPoolExecutor`, which executes tasks in separate processes.[2] Python threads are not part of this comparison.

I test 1, 2, 4, 6, 8 and 12 processes. Each configuration runs five times. The median is the main value; minimum and maximum form the observed range in the runtime charts. ProcessPool time is end-to-end: it includes process management, task distribution, calculation, returning results, reduction and pool shutdown. That is deliberately more than timing only the inner loop.

For memory, `psutil` samples the RSS of the parent process and its child processes about every 15 milliseconds. RSS is the resident memory the operating system associates with a process. It is an approximation, not an exact count of physical RAM pages exclusively occupied by the program.

Three problem sizes, two measurement blocks

Small, Medium and Large are not arbitrary labels. The full run calibrates N automatically against sequential runtime targets. The resulting values were 3,771,022 intervals for Small, 18,997,300 for Medium and 75,392,548 for Large. Their sequential median runtimes in the problem-size experiment were about 0.234 s, 1.170 s and 4.763 s.

It is important not to mix the Large reference from this block with strong scaling. Strong scaling had its own sequential baseline of 4.056 s. The problem-size and strong-scaling tests were separate runs, and each keeps its own reference time. Five repetitions and a median do not make measurements immutable: background load, temperature and scheduling can still change between runs.

Result 1: much faster, but not twelve times

In strong scaling, N stays fixed at 75,392,548. The direct sequential reference is 4.056 s. With two worker processes, the median falls to 2.108 s (speedup 1.92); with four, 1.128 s (3.60); with six, 0.877 s (4.62); with eight, 0.757 s (5.36); and with twelve, 0.572 s (7.09).

Twelve processes make the calculation noticeably faster. But the curve does not reach the ideal twelve. The dashed line in the chart shows S = P: the case where speedup grows in direct proportion to process count. The measured curve stays below it. That gap is not a surprise; it is where non-parallel work and the costs of parallelizing become visible.

Strong scaling at constant N: measured speedup for 1, 2, 4, 6, 8 and 12 workers compared with the dashed ideal line S equals P.
Strong scaling at constant N = 75,392,548. The dashed line marks S = P; with 12 processes the measured speedup is 7.09 rather than 12.

Result 2: efficiency declines

Efficiency in the strong-scaling block is about 96 percent with two processes, 90 percent with four, 77 percent with six and 67 percent with eight. At twelve processes it is roughly 59 percent. The calculation keeps getting faster, but every additional process contributes proportionally less speedup than the early ones did.

Those 59 percent do not mean 59 percent CPU utilization. Efficiency here is speedup divided by process count. CPU utilization is a different measure, and I did not measure it in this experiment.

Strong-scaling efficiency drops from 100 percent at one process through about 96, 90, 77 and 67 percent to around 59 percent at twelve processes.
Efficiency = speedup / number of processes. The decline shows diminishing speedup per added process, not CPU utilization.

Finishing sooner does not mean using fewer compute resources

In the same strong-scaling block, parallel costs P × T_P rise from about 4.06 processor-seconds at the sequential reference to about 6.87 with twelve processes. The answer arrives much earlier, but this simple cost measure counts more concurrently provisioned compute units over that runtime.

This is neither an energy measurement nor actual process CPU time. It is a theoretical comparison measure that makes the trade-off tangible: shorter waiting time can come with higher total parallel costs.

Result 3: larger tasks benefit more

In the separate problem-size experiment, twelve processes reach a speedup of 4.38 for Small, 6.68 for Medium and 8.56 for Large. Each value uses the direct sequential baseline for that same problem size. These speedups are not mixed with the strong-scaling curve.

One plausible explanation is that actual computation makes up a larger share of a bigger task, so one-time management and distribution overhead matters relatively less. That is an interpretation, not a single cause proven in isolation by this experiment. What was measured is the difference across these three sizes under these conditions.

Speedup for Small, Medium and Large at 1, 2, 4, 6, 8 and 12 workers; the Large curve rises highest.
Problem-size experiment: at 12 processes the speedups are 4.38 (Small), 6.68 (Medium) and 8.56 (Large), each against its own baseline.

Result 4: memory follows a different curve

Observed peak RSS in the problem-size experiment is about 70 MB for the sequential runs. With twelve processes, Small, Medium and Large each use roughly 299–300 MB. At a given process count, the three problem sizes almost overlap in the chart.

That fits the implementation: N mainly determines how many times the calculation runs. Intermediate values are not stored in a large collection; each process computes one value and adds it immediately to its local sum. A larger N therefore mainly increases runtime here, while the extra memory comes mostly from additional Python processes, their runtime environments and management data.

RSS remains an operating-system approximation. Shared pages, such as libraries, may appear more than once in the summed process values. The curve shows observed peak RSS according to the benchmark’s sampling method, not the exact amount of exclusively occupied RAM.

Peak RSS in megabytes by worker count: Small, Medium and Large nearly overlap at each process count; memory grows as workers are added.
Peak total RSS across parent and child processes. At 12 workers all three problem sizes are around 299–300 MB; RSS is an approximation and may count shared pages more than once.

Result 5: task size changes runtime

The granularity experiment keeps total work fixed and changes the number of tasks per worker: 1, 4, 16, 64 or 256. With twelve processes, median runtimes are 0.657 s, 0.637 s, 0.625 s, 0.632 s and 0.680 s.

In this run, 16 tasks per process was a little faster than the coarsest and finest splits. Some subdivision can distribute independent ranges more flexibly. Very small tasks add scheduling and management work; very large ones may make balancing coarser. This does not make 16 a universal optimum – it only shows task size was measurable in this benchmark.

Median runtime at 4, 8 and 12 workers across 1, 4, 16, 64 and 256 tasks per worker, with minimum-to-maximum error bars.
Task granularity with total work held constant. In this run the 12-worker series is fastest at 16 tasks per worker; bars show minimum and maximum across five repetitions.

What I take away from the experiment

Before the experiment, “more cores” mostly sounded like simple arithmetic to me: divide the work, calculate simultaneously, save time. The measurements confirm the important part – the Large calculation became much faster. But they also show what that simple picture leaves out: speedup stayed below process count, efficiency fell, the RSS estimate rose, and even task splitting changed the result.

The more useful question is not “How many processes can I start?” but “How much parallelism makes sense for this particular task?” I need to consider runtime, management, memory and benefit together. To me, parallelization is not a free speed multiplier; it is an engineering trade-off.

Limits of the experiment

The result applies first to one CPU-heavy numerical integration in pure Python using ProcessPoolExecutor – not automatically to every algorithm or programming language. The full run records an Intel Core Ultra 5 125H with Linux and Python 3.14.4 as its test system. CPU affinity was not enforced; the OS scheduler and changing system load remain part of the conditions.

RSS is a sampled approximation and may count shared memory pages more than once. P × T_P is a theoretical cost measure, not energy or a direct CPU-time measurement. The separate measurement blocks also varied, so I use each series’ own sequential baseline. This does not fully answer the broader question of why chipmakers do not add cores without limit: I did not investigate manufacturing, die area, energy efficiency or economics. My benchmark only examines what software gains from additional parallelism.

Sources and code

The measurements, raw data, configuration and figures come from my own repository. The links below support the short background on parallel algorithms, the midpoint rule and Python process pools.