Towards Data Science

I Reran My Python 3.14 JIT Benchmarks on Python 3.15

6.9内容质量

TL;DR · AI 摘要

I Reran My Python 3.14 JIT Benchmarks on Python 3.15 Towards Data Science Programming I Reran My Python 3.14 JIT Benchma...

核心要点

  • 主题聚焦:I Reran My Python 3.14 JIT Benchmarks on Python
  • 来源:Towards Data Science,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#产品
打开原文

I Reran My Python 3.14 JIT Benchmarks on Python 3.15 | Towards Data Science

Programming

I Reran My Python 3.14 JIT Benchmarks on Python 3.15

The same three workloads show a much stronger JIT, but it still doesn't help every program

Thomas Reid

October 16, 2026

13 min read

Prefer us on Google

Share

Image by AI

Earlier this year , I tested the experimental JIT compiler included with Python 3.14 (article link at the end). Two of my three workloads became noticeably faster. A Mandelbrot calculation improved by about 25%, a batch of Levenshtein distance calculations by 27%, and a Dijkstra graph search didn’t improve at all.

That was a promising start, although the JIT in 3.14 was still fairly limited. Python 3.15 has since substantially rebuilt it. Rather than repeat the theory from my earlier article, I wanted to ask a more useful question: what happens if I run exactly the same code on both versions?

I tested four configurations:

  • Python 3.14 with the JIT disabled
  • Python 3.14 with the JIT enabled
  • Python 3.15 with the JIT disabled
  • Python 3.15 with the JIT enabled

This is important because a simple 3.14-versus-3.15 comparison can be misleading. Python 3.15 contains interpreter improvements outside the JIT, including a change to the interpreter used by official 64-bit Windows builds. Running all four configurations lets us see whether 3.15 itself is faster and whether switching on its JIT adds anything.

A quick recap of the Python 3.14 test

CPython normally compiles a .py file into bytecode, then its interpreter works through those bytecode instructions. The experimental JIT adds another tier. Once a path through the program becomes hot, CPython can turn that path into native machine code and execute it directly.

The 3.14 JIT uses a copy-and-patch design. CPython prepares much of the native code while it builds itself. At runtime, the JIT copies the pieces it needs and patches in values such as addresses and constants. This keeps the runtime compiler relatively small, so you don’t need LLVM installed to use it.

Note: LLVM is a collection of compiler tools that can generate machine code for different processors. Python’s build process uses it to prepare small machine-code templates for the JIT. At runtime, CPython copies and adjusts those templates when it compiles a frequently executed path.

My previous test used three pure-Python workloads:

  • Mandelbrot: nested loops and floating-point arithmetic over a 1,000 by 1,000 grid.
  • Dijkstra: graph construction followed by a shortest-path search using lists, tuples and heapq.
  • Levenshtein: dynamic programming over 10,000 pairs of generated strings.

The first and third gave the JIT plenty of repeated Python work to optimise, however, Dijkstra spent more time allocating objects and calling operations implemented elsewhere, so the 3.14 JIT had less to work with.

That’s enough background for this article. The interesting part is what has changed inside 3.15.

Why the 3.15 JIT is different

The 3.14 JIT tried to predict likely paths through bytecode. In 3.15, a new tracing front end records the path that the program actually takes. This allows it to trace more control flow and compile a wider range of Python code.

The JIT optimiser has also grown up. Python 3.15 adds basic register allocation, does more constant propagation and can avoid some reference-count operations when it can prove they’re unnecessary. It can track uniquely referenced integers and floats too, allowing some operations to update an object in place instead of allocating another one. Code generation for x86–64 and AArch64 has improved, while the generated machine code uses less memory.

LLVM 21 is involved when CPython’s JIT files are built, but it’s a build-time tool. If you use an official binary, you don’t need to install LLVM yourself.

The Python release team reports a 7% to 8% geometric-mean improvement over the standard interpreter on its x86–64 Linux benchmark suite, and 11% to 12% on AArch64 macOS. Those are averages across many programs, not a promise about any one script. A small set of deliberately hot loops can produce very different numbers, as mine did.

There’s another change to bear in mind on Windows. Official 64-bit 3.15 binaries use CPython’s tail-calling interpreter. Despite the name, this isn’t about whether your own functions use tail recursion. It’s an alternative way of moving between the small C functions that implement Python instructions. On a supported compiler and processor, it gives the CPU a more predictable dispatch path.

That interpreter runs even when the JIT is turned off, so it can improve the 3.15 baseline before the JIT enters the picture. This is one reason I haven’t reduced the results to a single 3.14-versus-3.15 percentage. The off/off comparison measures the new version and its interpreter. The on/off comparison within each version measures what enabling that version’s JIT did. The on/on comparison is what a user already running the 3.14 JIT is likely to notice after upgrading.

Download Python 3.14 & 3.15 on Windows

You can keep Python 3.14 and 3.15 installed side by side.

Head over to https://www.python.org/downloads/ , and you’ll see a section like this where you can download both versions.

Image from Python downloads site

Click on each and follow the instructions.

Once installation is complete for both, open a new PowerShell window and check both interpreters.

Note: The Python Install Manager provides a separate command called py. When you run py -3.15, for example, it finds the matching Python installation and launches its python.exe.

powershell

code
PS D:\projects> py -3.14 - version
PS D:\projects> py -3.15 - version

I already had Python 3.14.7 installed, so I just downloaded 3.15. In any case, you should see output resembling this:

code
Python 3.14.7 (tags/v3.14.7:823f032, Aug  5 2026, 10:51:32) [MSC v.1944 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
Python 3.15.0 (tags/v3.15.0:d220409, Oct  9 2026, 11:55:56) [MSC v.1951 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.

Check that your versions support the JIT:

code
PS D:\projects> py -3.14 -c "import sys; print(sys._jit.is_available())"
PS D:\projects> py -3.15 -c "import sys; print(sys._jit.is_available())"

Both commands should print True . If either prints False , that interpreter wasn’t built with JIT support. Building Python from scratch with the JIT enabled is beyond the scope of this article, so refer to the appropriate documentation for guidance.

Enabling the JIT

By default, the JIT is disabled . This is a safety measure; because it is experimental, the Python Steering Council wants to ensure that users don’t face unexpected regressions in stability or memory usage without explicitly choosing to.

To activate the JIT, you use an environment variable. This tells the CPython runtime to initialise the JIT engine upon startup.

To enable the JIT, set the environment variable PYTHON_JIT to “1”. Set it to “0” to disable it again. For example,

code
PS D:\projects> $env:PYTHON_JIT="1"
or
PS D:\projects> $env:PYTHON_JIT="0"

#### Creating the new benchmarks

Make a new directory and move into it:

code
PS D:\projects> mkdir python-jit-test
PS D:\projects> cd python-jit-test

The benchmark uses only the standard library, so there are no packages to install.

Create a file workloads.py with the following code:

python

code
# workloads.py
from __future__ import annotations
import heapq
import random
def mandelbrot(width: int = 1000, height: int = 1000, iters: int = 500) -> int:
checksum = 0
for y in range(height):
cy = (y / height) * 2.4 - 1.2
for x in range(width):
cx = (x / width) * 3.2 - 2.2
zx, zy, count = 0.0, 0.0, 0
while zx * zx + zy * zy <= 4.0 and count < iters:
zx, zy = zx * zx - zy * zy + cx, 2.0 * zx * zy + cy
count += 1
checksum += count
return checksum
def dijkstra(n: int = 10000, edges_per_node: int = 50, seed: int = 123) -> int:
rng = random.Random(seed)
graph = [[] for _ in range(n)]
for node in range(n):
for _ in range(edges_per_node):
neighbour = rng.randrange(n)
if neighbour != node:
graph[node].append((neighbour, rng.randrange(1, 30)))
distances = [10**12] * n
distances[0] = 0
queue = [(0, 0)]
visited = 0
while queue:
distance, node = heapq.heappop(queue)
if distance != distances[node]:
continue
visited += 1
for neighbour, weight in graph[node]:
new_distance = distance + weight
if new_distance < distances[neighbour]:
distances[neighbour] = new_distance
heapq.heappush(queue, (new_distance, neighbour))
return visited
def levenshtein(a: str, b: str) -> int:
previous = list(range(len(b) + 1))
for i, char_a in enumerate(a, 1):
current = [i]
for j, char_b in enumerate(b, 1):
current.append(
min(
current[j - 1] + 1,
previous[j] + 1,
previous[j - 1] + (char_a != char_b),
)
)
previous = current
return previous[-1]
def levenshtein_batch(n: int = 10000, seed: int = 7, k: int = 50) -> int:
rng = random.Random(seed)
alphabet = "abc"
total = 0
for _ in range(n):
a = "".join(rng.choices(alphabet, k=k))
b = "".join(rng.choices(alphabet, k=k))
total += levenshtein(a, b)
return total

The fixed seeds make the graph and strings repeatable. Each function also returns a checksum. The runner checks it after every sample, so a fast run that produces the wrong answer can’t slip into the results.

Now create benchmark_versions.py:

code
# benchmark_versions.py
from __future__ import annotations
import argparse
import json
import os
import random
import statistics
import subprocess
import time
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent
OUTPUT_FILE = PROJECT_DIR / "results_python_314_vs_315.json"
WORKLOADS = {
"mandelbrot": (
"from workloads import mandelbrot; result = mandelbrot()",
103566912,
),
"dijkstra": (
"from workloads import dijkstra; result = dijkstra()",
10000,
),
"levenshtein_batch": (
"from workloads import levenshtein_batch; result = levenshtein_batch()",
243833,
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--python314", required=True, type=Path)
parser.add_argument("--python315", required=True, type=Path)
parser.add_argument("--runs", type=int, default=10)
return parser.parse_args()
def run_process(executable: Path, code: str, jit: int):
environment = os.environ.copy()
environment["PYTHON_JIT"] = str(jit)
completed = subprocess.run(
[str(executable), "-c", code],
cwd=PROJECT_DIR,
env=environment,
capture_output=True,
text=True,
)
if completed.returncode:
raise RuntimeError(completed.stderr)
return completed
def check_interpreter(executable: Path, jit: int) -> dict:
code = (
"import json, platform, sys; "
"print(json.dumps({"
"'version': platform.python_version(), "
"'executable': sys.executable, "
"'jit_available': sys._jit.is_available(), "
"'jit_enabled': sys._jit.is_enabled()}))"
)
details = json.loads(run_process(executable, code, jit).stdout)
if not details["jit_available"]:
raise RuntimeError(f"{executable} has no JIT support")
if details["jit_enabled"] is not bool(jit):
raise RuntimeError(f"Couldn't set PYTHON_JIT={jit} for {executable}")
return details
def run_workload(executable: Path, statement: str, jit: int):
started = time.perf_counter()
completed = run_process(executable, statement + "; print(result)", jit)
return time.perf_counter() - started, int(completed.stdout.strip())
def summarise(samples: list[float]) -> dict:
return {
"mean_seconds": statistics.fmean(samples),
"median_seconds": statistics.median(samples),
"minimum_seconds": min(samples),
"maximum_seconds": max(samples),
"samples_seconds": samples,
}
def main() -> int:
args = parse_args()
interpreters = {
"python_314": args.python314.resolve(),
"python_315": args.python315.resolve(),
}
for executable in interpreters.values():
if not executable.is_file():
raise FileNotFoundError(executable)
configurations = [
(name, executable, jit)
for name, executable in interpreters.items()
for jit in (0, 1)
]
details = {
f"{name}_jit_{jit}": check_interpreter(executable, jit)
for name, executable, jit in configurations
}
results = {
"runs_per_configuration": args.runs,
"interpreters": details,
"workloads": {},
}
rng = random.Random(315)
for workload_name, (statement, expected) in WORKLOADS.items():
print(f"\n{workload_name}", flush=True)
samples = {
f"{name}_jit_{jit}": []
for name, _executable, jit in configurations
}
for round_number in range(1, args.runs + 1):
run_order = configurations.copy()
rng.shuffle(run_order)
for name, executable, jit in run_order:
key = f"{name}_jit_{jit}"
elapsed, output = run_workload(executable, statement, jit)
if output != expected:
raise RuntimeError(
f"{workload_name} returned {output}; expected {expected}"
)
samples[key].append(elapsed)
print(
f"  round {round_number:02d} {key}: {elapsed:.6f}s",
flush=True,
)
results["workloads"][workload_name] = {
key: summarise(values) for key, values in samples.items()
}
OUTPUT_FILE.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(f"\nWrote {OUTPUT_FILE}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

#### Find the two Python executables

The runner needs full paths because it starts each interpreter directly. PowerShell can obtain them from the launcher:

code
PS D:\projects\python-jit-test> $python314 = py -3.14 -c "import sys; print(sys.executable)"
PS D:\projects\python-jit-test> $python315 = py -3.15 -c "import sys; print(sys.executable)"
PS D:\projects\python-jit-test> $python314
PS D:\projects\python-jit-test> $python315
C:\Users\thoma\AppData\Local\Programs\Python\Python314\python.exe
C:\Users\thoma\AppData\Local\Python\pythoncore-3.15-64\python.exe

Run the benchmark like this:

code
PS D:\projects\python-jit-test>py -3.14 benchmark_versions.py `
--python314 $python314 `
--python315 $python315 `
--runs 10

It doesn’t matter which Python version you use to kick this off. The measured workloads run in separate child processes using the paths supplied through python314 and python315 . Therefore, either Python version can run the controller.

Allow several minutes for things to complete. The script starts a new child process for every sample, sets PYTHON_JIT before that process starts and checks whether the requested JIT state took effect. It also mixes the order of the four configurations within each round. Running every 3.14 test first and every 3.15 test later would make a Windows update, background scan or change in CPU temperature more likely to favour one version.

This method has an important limit. Each sample includes interpreter startup and one complete workload. The loops inside Mandelbrot and Levenshtein run long enough to become hot during that process, but the ten outer samples aren’t a JIT warm-up. They’re ten independent processes. This preserves the method used for my earlier article and measures a realistic command-line run, but it doesn’t tell us how a web service behaves after several hours, for example.

I report median timings in my results rather than choosing the fastest run. A minimum is tempting because it removes obvious background interruptions, but it also describes the luckiest sample rather than a typical one. A mean uses every observation, although one antivirus scan or similar system interruption can pull it upwards. The median is simply the middle of the ten sorted times and behaved well here. The JSON file still contains every sample, plus the mean, minimum and maximum, so you can inspect the spread or use another statistic.

The script also verifies the JIT state in a separate child process before timing begins. Setting the PYTHON_JIT variable after Python has started is too late because CPython reads it during startup. Checking sys._ jit.is _enabled() inside the parent benchmark process would only describe the parent, not the 3.14 and 3.15 children we’re trying to compare.

My results

I ran the test on Windows 11 with a 13th-generation Intel Core i9–13900K and 64 GB of RAM. I used Python 3.14.7 and Python 3.15.0. The table contains the median of ten fresh-process runs, which prevents one interruption from distorting a short test.

plaintext

code
+-------------------+--------------+-------------+--------------+-------------+---------------+
| Workload          | 3.14 JIT off | 3.14 JIT on | 3.15 JIT off | 3.15 JIT on | 3.15 JIT gain |
+-------------------+--------------+-------------+--------------+-------------+---------------+
| Mandelbrot        |      7.207 s |     5.250 s |      5.313 s |     2.471 s |         53.5% |
| Dijkstra          |      0.241 s |     0.241 s |      0.198 s |     0.202 s |         -1.6% |
| Levenshtein batch |      2.192 s |     1.676 s |      1.547 s |     1.385 s |         10.4% |
+-------------------+--------------+-------------+--------------+-------------+---------------+

The 3.15 JIT gain column compares 3.15 with its own JIT switched off and on. That’s the cleanest way to judge the JIT itself.

Mandelbrot: the large win

The Mandelbrot workload took 5.25 seconds with the 3.14 JIT and 2.471 seconds with the 3.15 JIT. That’s a 53% reduction between the two JIT-enabled versions. Not too shabby.

Some of that story belongs to the rest of Python 3.15. Even with the JIT disabled, 3.15 completed the test in 5.313 seconds, 26% faster than 3.14 without its JIT.

This workload is unusually favourable. Most of the time is spent in one predictable nest of Python loops doing arithmetic on local values. The tracing front end sees the same path repeatedly, and the newer optimiser can remove more interpreter and object-management work from it.

Dijkstra: leave the JIT off

Dijkstra remains the awkward case. On 3.14, enabling the JIT left the median unchanged at 0.241 seconds. On 3.15, turning on the JIT actually degraded the run-time performance.

That said, Python 3.15 still ran this workload roughly 16% faster with the JIT disabled than 3.14 did with or without the JIT. That tells us the version upgrade helped, not the JIT. Graph creation, tuple allocation, random-number generation and calls into heapq don’t resemble the tight arithmetic loop that made Mandelbrot such a good target.

Startup is also a larger share of a 0.2-second command. If this were an important production path, I’d enlarge the graph, run the search repeatedly inside one process and profile it before making any decision.

Levenshtein: faster, but the extra JIT gain is smaller

Levenshtein dropped from 1.676 seconds with the 3.14 JIT to 1.389 seconds with the 3.15 JIT, an 18.6% reduction. Yet 3.15 without the JIT was already down to 1.553 seconds. Against that baseline, its JIT contributed a further 10.5%.

This is why the four-column comparison is worthwhile. Saying “3.15 JIT is 18.6% faster than 3.14 JIT” is true, but it credits the JIT with improvements that also appear when the JIT is off.

Should you switch the JIT on?

I wouldn’t enable the JIT everywhere on the strength of three synthetic tests. I would try it on a CPU-bound program dominated by Python loops, especially if it runs long enough for hot paths to matter. Mandelbrot shows how large the upside can now be. Dijkstra shows that the switch can even degrade performance given the right set of circumstances.

Summary

Test the application you actually run, using representative input. Measure elapsed time, memory and latency rather than one headline throughput figure. Keep the non-JIT run as the baseline, and don’t confuse a general 3.15 improvement with a JIT improvement.

The bigger conclusion is that Python’s experimental JIT has moved on considerably since 3.14. In the old test, it helped two loop-heavy programs by about a quarter. In 3.15, one of those programs became more than twice as fast when the JIT was enabled. The other gained 10.5%, but the graph workload still said no thanks!

It looks like the JIT is starting to make a big difference for code it understands, but you should benchmark it rather than trust it at first glance and apply it willy-nilly.

To review my previous article on the Python 3.14 JIT compiler, click here .