Skip to content

[Feature] Efficient DTW in C++ - #3141

Draft
daidahao wants to merge 17 commits into
unit8co:masterfrom
daidahao:feature/dtw-cpp
Draft

[Feature] Efficient DTW in C++#3141
daidahao wants to merge 17 commits into
unit8co:masterfrom
daidahao:feature/dtw-cpp

Conversation

@daidahao

@daidahao daidahao commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Checklist before merging this PR:

  • Mentioned all issues that this PR fixes or addresses.
  • Summarized the updates of this PR under Summary.
  • Added an entry under Unreleased in the Changelog.

Fixes #3140 .

Summary

This is a draft PR to showcase the potential speedup of DTW in C++ and invite discussion.

Note: I wrote most of the code and PR description two months ago while waiting for explainer merge, so some of my opinions have changed since then. I will update the description to reflect those changes soon...

This PR proposes migrating the DTW distance to C++ in nanobind to significantly speed up the distance calculation.
This would also be a first step towards classification and clustering support in Darts and would allow us to implement distance measures other than DTW, classification and clustering algorithms in C++ in the future.

Distance metrics are central to time series classification and clustering, and DTW is a widely used distance measure for time series.
Classification and clustering algorithms often require computing pairwise distances between time series, which can be computationally expensive.

However, the current DTW implementation in Darts is in pure Python, which can be slow for large datasets or long time series.
The new implementation (dtw.cpp) replaces _dtw_cost_matrix() and is written in C++ and exposed to Python using nanobind.

Current Limitations:

  • No windowing, no multi-grid radius.
  • It uses the same algorithm and cost matrix as the old one, so the time and space complexity are the same, $O(n*m)$ for two time series of lengths n and m.
  • It can be further optimized by using a last two-row cost matrix instead of the full cost matrix, which would reduce the space complexity to $O(\min(n,m))$, but DTWAlignment would not be supported anymore. Only distance would be returned, not the optimal warping path.

Benchmark Results

import time
from itertools import product
from typing import Any

import pandas as pd

from darts.dataprocessing.dtw import dtw
from darts.dataprocessing.dtw.dtw_old import dtw as dtw_old
from darts.utils.timeseries_generation import gaussian_timeseries

n_x_steps = [10, 100, 1000]
n_y_steps = [10, 100, 1000]
n_trials = 10

results = []
for n_x, n_y in product(n_x_steps, n_y_steps):
    result: dict[str, Any] = {
        "n_x": n_x,
        "n_y": n_y,
    }
    for dtw_name, dtw_func in [('dtw_old', dtw_old), ('dtw', dtw)]:
        series1 = gaussian_timeseries(length=n_x)
        series2 = gaussian_timeseries(length=n_y)
        start_time = time.perf_counter()
        for _ in range(n_trials):
            dtw_func(series1, series2)
        end_time = time.perf_counter()
        execution_time = (end_time - start_time) / n_trials
        print(f"DTW function: {dtw_func.__name__}, n_x: {n_x}, n_y: {n_y}, average execution time: {execution_time:.6f} seconds")
        result[dtw_name] = execution_time
    result["speedup"] = result["dtw_old"] / result["dtw"]
    results.append(result)

df = pd.DataFrame(results)
n_x n_y dtw_old dtw speedup
0 10 10 7.96625e-05 1.33292e-05 5.97654
1 10 100 0.000579121 1.38458e-05 41.8265
2 10 1000 0.00478302 3.41458e-05 140.077
3 100 10 0.000392117 9.0541e-06 43.3082
4 100 100 0.00383549 1.91583e-05 200.2
5 100 1000 0.0395192 0.000184846 213.796
6 1000 10 0.0038472 2.10125e-05 183.091
7 1000 100 0.0389327 0.000121283 321.006
8 1000 1000 0.407602 0.00158128 257.767

Note that both implementations are running on a single thread, which is intentional because in clustering and classification, we would be computing pairwise distances in parallel across multiple threads or processes.

Design Decisions

  • Why C++? C++ is faster than Python with little overhead, especially for tasks like distance calculations that involve nested loops and can benefit from low-level optimizations. C++ is also familar to many developers and easy to maintain.
  • Why nanobind? Nanobind is a modern C++ binding library that supersedes pybind11 by the same author. It offers better performance and used in many Python libraries such as JAX, MLX.
  • Why not numba? Numba is useful for accelerating numpy code and used in aeon & sktime for distance calculations. While not a Darts dependency, it is de facto included in Darts via shap (shap is moving away from numba to nanobind, see shap project, shap #4327) & pyod. However, numba requires compilation at runtime (cold start), and usually falls behind Python release cycles (e.g., 3.14).
  • Backward compatibility: The new C++ implementation is designed to be a drop-in replacement for the old Python implementation, so it should not break any existing code that uses DTW. We can keep the old implementation for now and deprecate it in the future once we have fully migrated to C++.

Implications

  • Performance: The new C++ implementation should significantly speed up DTW distance calculations, especially for long time series or large datasets. This would make distance-based classification and clustering algorithms more feasible in Darts.
  • Migration: Some existing algorihtms (heavy in numpy operations) in Darts could also migrate to C++ in the future to improve performance.
  • Distribution(!): Binary wheels would need to be built for different platforms to distribute the C++ extension, but tools like cibuildwheel can automate this process. CI need to be updated to build and test the C++ extension on different platforms.

TODO

Should we agree on this approach, the next steps in this PR would be:

  1. Backward compatible: Migrate the fast DTW (multi_grid_radius>=0) algorithm to C++ as well. I would now suggest deprecating fastDTW algorithm, because DTW with proper windowing is faster and exact, see this paper.
  2. Deprecate the distance argument (in DistanceFunc) in DTWAlignment and only support pre-defined distance options (in str) such as euclidean, manhattan, etc. that can be directly implemented in C++. Otherwise, calling a DistanceFunc from C++ would add overhead and reduce the speedup.
  3. Backward compatible: Keep CostMatrix class and subclasses in the Python scope but exposes .dense numy array to support the C++ implementation.
  4. Backward compatible: Keep Window class and subclasses in the Python scope but expose the necessary information (column_ranges) to support windowing in the C++ implementation.
  5. Backward compatible: Support CR windowing in the C++ implementation.
  6. Add darts.distances module with DTW distance and other distance measures (e.g., euclidean, manhattan) functions, like darts.metrics for forecasting.
  7. For the darts.distances.dtw function, we can implement a more efficient version using two-row rolling cost matrix.

Wheel Distribution

To distribute the C++ extension, we would need to build binary wheels for combinations of platforms, architectures, and Python minors:

  • Platforms: Windows, macOS, Linux
  • Architectures: x86_64, arm64.
  • Python minors: 3.10-3.14, 3.14t.

We can use cibuildwheel to automate the building and testing of those wheels. However, cibuildwheel by default builds for all combinations, resulting in a large number of wheels (see scikit-learn for example). We will not have the means and resources to maintain such a large number of wheels.

I propose two strategies to reduce the number of wheels:

  1. Python minor + Stable ABI: nanobind supports building with stable ABI for Python 3.12+. There are, however, two caveats: (1) we still need to build separate wheels for 3.10 and 3.11; (2) there is currently no stable ABI for free-threading 3.14t, though it is expected for 3.15 (PEP 803). So we need to build for 3.10, 3.11, 3.12+ (stable ABI), and 3.14t (reduced by 33%).
  2. Universal macOS wheel: macOS supports universal wheels that can run on both x86_64 and arm64 architectures. We reduce the number of wheels by half for macOS.

Including the most common platforms and architectures, here is a breakdown of 28 wheels needed with the above strategies:

  • Linux: 2 platforms (manylinux_2_28, musllinux_1_2) * 2 architectures (x86_64, arm64) * 4 minors = 16 wheels.
  • Windows: 2 architectures (amd64, arm64) * 4 minors = 8 wheels.
  • macOS: 1 platform (universal2) * 4 minors = 4 wheels.

I created an example repository here to test building and distributing nanobind extensions with cibuildwheel.

We could reduce the number further by excluding musllinux_1_2 for Linux and arm64 for Windows. So we would have 16 wheels in total:

  • Linux: 1 platform (manylinux_2_28) * 2 architectures (x86_64, arm64) * 4 minors = 8 wheels.
  • Windows: 1 architecture (amd64) * 4 minors = 4 wheels.
  • macOS: 1 platform (universal2) * 4 minors = 4 wheels.

Other Information

daidahao added 17 commits June 20, 2026 17:31
- Update build config.
- Add `dtw_cost_matrix_no_window_1d` (limited use cases for now).

Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Otherwise, GIL is enabled globally.

Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
Co-authored-by: Zhihao Dai <zhihao.dai@eng.ox.ac.uk>
@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 14.00000% with 129 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.96%. Comparing base (0069263) to head (bba11bf).

Files with missing lines Patch % Lines
darts/dataprocessing/dtw/dtw_old.py 0.00% 129 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3141      +/-   ##
==========================================
- Coverage   96.73%   95.96%   -0.77%     
==========================================
  Files         163      164       +1     
  Lines       17536    17678     +142     
==========================================
+ Hits        16963    16965       +2     
- Misses        573      713     +140     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dennisbader

Copy link
Copy Markdown
Collaborator

Thanks a lot @daidahao for starting the initiative towards optimizing some of our heavy-duty python code.

Since we don't have any code yet in another language, I would like to first think about what the best / most future-proof language would be for us. Any future refactor should ideally then use the selected language to improve maintainability.

I feel that the momentum has been shifting towards Rust since a while now, and I see many other libraries / software using it at the moment (Polars, uv, ty, ...). It seems to have proven itself to be robust at scale.

Have you thought about Rust or at least why C++ would be the right choice for us compared to other languages?

@daidahao

Copy link
Copy Markdown
Contributor Author

Have you thought about Rust or at least why C++ would be the right choice for us compared to other languages?

I am neutral on Rust because I have not used Rust before. I chose C++ and nanobind mainly for two reasons:

  1. C++ is a language familiar to many developers. There should be very little concern about its readability and maintainability given its popularity and comprehensive documentation. It is possible to write Python extension in pure C, but we would be making many boilerplate Python C API calls just to read/write numpy arrays. Cython is another option but we will be using setup.py again.
  2. nanobind, one of Py/C++ bindings, succeeds pybind11 but with a more pythonic design ("The codebase has to adapt to the binding tool and not the other way around"). nanobind has been powering ML libraries like JAX, MLX. It offers features that could directly benefit Darts: ABI compatibility (reducing wheel count), numpy array exchange (zero-copy), free-threading support (Py3.14+).*

*PyO3, the popular Py/Rust binder, might offer those features too. Again, I am not familiar with Rust.

That said, it might still be worth comparing others to C++/nanobind. My LLM suggests that Rust might be preferred if memory safety is a key concern (that might not be the case here due to Darts nature). In terms of future-proofness, I am quite confident about nanobind given its vast usage in ML.

@dennisbader
What's your opinion on Rust vs C++?

@daidahao
daidahao marked this pull request as draft June 25, 2026 13:45
@daidahao

Copy link
Copy Markdown
Contributor Author

Following offline discussion, I will postpone this PR in order to give us time to think about clustering API design first. We could circle back should we need distance-based clustering and efficient distance metrics in Darts in the future.

@dennisbader

Copy link
Copy Markdown
Collaborator

Thanks @daidahao, let's come back to this once we progressed more on the clustering design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Efficient DTW and clustering

2 participants