Skip to content

feat(configs): add HDBSCAN benchmark configs - #225

Open
Alexandr-Solovev wants to merge 3 commits into
IntelPython:mainfrom
Alexandr-Solovev:dev/hdbscan_bench
Open

Alexandr-Solovev wants to merge 3 commits into
IntelPython:mainfrom
Alexandr-Solovev:dev/hdbscan_bench

Conversation

@Alexandr-Solovev

Copy link
Copy Markdown

Description

Adds HDBSCAN benchmark configs (sklearn vs. sklearnex.preview.cluster.HDBSCAN, CPU) plus the two harness fixes needed to make them runnable and reportable.

Configs

File Contents
configs/common/hdbscan.json Reusable parameter sets — per-method (brute / kd_tree / ball_tree), metrics, density / cluster-selection / store_centers sweeps, dtypes, data formats. Modelled on the existing DBSCAN common config.
configs/regular/hdbscan.json Tree-friendly datasets (skin_segmentation, road_network, covtype, low/mid-dimensional blobs) and brute-friendly ones (mnist, cifar, sensit, high-dimensional blobs).
configs/experiments/hdbscan_parameters.json Parameter-space sweeps.
configs/experiments/hdbscan_scaling.json Thread / NUMA core scaling plus n_samples and n_features sweeps.

The dataset split between tree- and brute-friendly is deliberate: HDBSCAN's tree methods degrade with dimensionality, so mnist/cifar/sensit and the wide blobs are benchmarked under brute only, and the 100k-row sets under the tree methods.

Harness fixes

1. enrich_result: normalize a *.preview.* library to its base library (sklbench/benchmarks/common.py)

HDBSCAN is only available from sklearnex.preview.cluster. Without this, the reported library string is sklearnex.preview.cluster, which does not match sklearn's row in the report, so the stock and accelerated results never get paired and no speedup column is produced. The existing .replace("sklbench.emulators.", "") / .replace(".utils", "") normalization is the same idea; this extends it to the preview namespace.

2. box_filter: fall back to the whole series when the 20–80% box is empty (sklbench/utils/measurement.py)

For a 2-element series, lower = array[0] and upper = array[1], and the filter keeps only items strictly between them — so the result is empty and the aggregated time becomes NaN. Two measurements happen in practice whenever a time_limit early stop triggers after the second run, which is easy to hit with HDBSCAN on stock scikit-learn. The existing size == 1 guard covers the 1-element case; this covers the empty-box case the same way.

Validation

Run on 2x Intel Xeon (112 threads), oneDAL main, scikit-learn 1.9.0, configs/regular/hdbscan.json with -p bench:time_limit=600. All 18 sklearn/sklearnex pairs produced and paired correctly in the report:

dataset method n p sklearn (s) sklearnex (s) speedup
skin_segmentation kd_tree 100000 3 47.11 0.12 389x
skin_segmentation ball_tree 100000 3 55.80 0.30 188x
road_network kd_tree 100000 3 25.56 0.08 314x
road_network ball_tree 100000 3 28.29 0.25 112x
covtype kd_tree 50000 54 184.81 4.45 42x
covtype ball_tree 50000 54 197.41 2.50 79x
make_blobs kd_tree 100000 4 / 16 / 64 48.64 / 103.12 / 278.25 0.10 / 1.03 / 11.42 476x / 100x / 24x
make_blobs ball_tree 100000 4 / 16 / 64 60.68 / 136.47 / 457.12 0.50 / 1.88 / 12.31 122x / 73x / 37x
mnist brute 20000 784 3.94 1.22 3.2x
cifar brute 15000 3072 3.24 1.11 2.9x
sensit brute 25000 100 5.43 1.66 3.3x
make_blobs brute 25000 64 / 256 / 1024 6.29 / 5.11 / 5.97 1.64 / 1.67 / 1.87 3.8x / 3.1x / 3.2x

Clustering quality is reported alongside and agrees closely — Davies-Bouldin matches to three decimals on every case, homogeneity/completeness match, and cluster counts agree to within ~0.8% (e.g. covtype 133 vs 133, skin_segmentation 4847 vs 4886).

Note that HDBSCAN picks up the existing clustering quality metrics for free: sklearn_estimator.py gates them on "DBSCAN" in str(estimator_instance), which HDBSCAN also satisfies, so cluster count, Davies-Bouldin, homogeneity and completeness are all populated with no further change.

No regression to existing behaviour: configs/sklearn_example.json (one of the configs CI runs) still reports libraries as sklearn / sklearnex across all 44 cases with no NaN timings.

🤖 Generated with Claude Code

Add sklearnex-vs-sklearn HDBSCAN CPU benchmarks:
* configs/common/hdbscan.json - reusable parameter sets (methods,
  metrics, density/cluster-selection/store_centers sweeps, dtypes,
  data formats), modelled on the DBSCAN common config
* configs/regular/hdbscan.json - tree- and brute-friendly datasets
* configs/experiments/hdbscan_parameters.json - parameter space sweeps
* configs/experiments/hdbscan_scaling.json - thread/NUMA core scaling
  plus n_samples and n_features sweeps

Two harness fixes are needed to make these runnable:
* enrich_result: normalize a `*.preview.*` library to its base library
  so preview-namespace estimators (sklearnex.preview.cluster.HDBSCAN)
  are comparable with the stock implementation in the report
* box_filter: fall back to the whole series when the 20-80% box is
  empty, which happens for 2 measurements - a `time_limit` early stop
  can leave exactly 2 and the aggregated time became NaN
@david-cortes-intel

Copy link
Copy Markdown
Contributor

@Alexandr-Solovev Please shorten the amount of benchmarks for the regular config. You can put them all into a separate one not under /common or /regular.

Review feedback: the regular scope was too heavy and the sweep parameter
sets only ever served the experiment configs.

* configs/regular/hdbscan.json: 36 -> 8 cases. One tree method (kd_tree)
  on two datasets and brute force on two, which is enough to track
  performance changes.
* configs/weekly/hdbscan.json: new, 30 cases. Takes over the high-load
  part of what regular used to run: road_network/covtype/cifar/sensit and
  the n_features blob sweeps over both tree methods.
* configs/common/hdbscan.json: keeps only the sets shared across scopes
  (implementations, common/sklearn parameters, method selection). The
  metric, cluster-selection, density, store_centers, dtype and data
  format sweeps moved into configs/experiments/hdbscan_parameters.json,
  their only consumer.
* configs/experiments/README.md: describe the two HDBSCAN experiments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Alexandr-Solovev

Copy link
Copy Markdown
Author

@david-cortes-intel Done in f2a8edd — thanks, that was the right call.

Config scope after the change

config cases what it is
configs/regular/hdbscan.json 36 → 8 two tree-friendly datasets (skin_segmentation 100k, blobs 100k×16) × kd_tree, two brute-friendly ones (MNIST 20k, blobs 25k×256) × brute, both libraries
configs/weekly/hdbscan.json 30 (new) the high-load cases — road_network/covtype 100k, blobs 100k×{4,16,64} on kd_tree+ball_tree; CIFAR 15k, SenSIT 25k, blobs 25k×{64,256,1024} on brute
configs/experiments/hdbscan_parameters.json 56 the parameter sweeps that used to live in configs/common/hdbscan.json: metrics, cluster_selection_method, min_cluster_size/min_samples, store_centers, dtype, data format/order

configs/common/hdbscan.json is back to being only the shared building blocks (library pair, default estimator params, per-method parameter sets) with no case-generating data sets of its own, so nothing under /common or /regular expands into a sweep any more.


Short report from these configs

Ran the whole set on a 2×56-core Xeon (224 threads), sklearn 1.9.0 vs sklearnex.preview.cluster on oneDAL main, n_runs=3, float64 unless noted. 90 measured cases, 45 of which are sklearn/sklearnex pairs.

Fit-time speedup (sklearn / sklearnex):

n geomean min max
all paired 45 63.1x 2.92x 475.8x
kd_tree 23 191.3x 24.4x 475.8x
ball_tree 11 96.3x 37.1x 187.9x
brute 11 4.1x 2.92x 6.8x

No regressions — the slowest paired case is still 2.9x. 34 of 45 are ≥10x.

By dataset: road_network 187.7x, make_blobs 76.9x, skin_segmentation 72.0x, covtype 57.3x, then the high-dimensional brute sets — sensit 3.3x, mnist 3.2x, cifar 2.9x.

Representative cases:

dataset method n × d sklearn sklearnex x
make_blobs kd_tree 100000 × 4 48.6 s 0.102 s 475.8
skin_segmentation kd_tree 100000 × 3 47.1 s 0.121 s 389.1
road_network kd_tree 100000 × 3 25.6 s 0.081 s 314.1
make_blobs kd_tree 100000 × 8, minkowski p=3 755.3 s 4.57 s 165.3
covtype ball_tree 50000 × 54 197.4 s 2.50 s 79.0
make_blobs kd_tree 100000 × 64 278.2 s 11.42 s 24.4
make_blobs brute 25000 × 64, cosine 5.39 s 0.79 s 6.8
cifar brute 15000 × 3072 3.24 s 1.11 s 2.9

Quality. Cluster count, Davies-Bouldin, homogeneity and completeness match sklearn to 3-4 digits on 43 of the 45 pairs. The two that differ are both tie-degenerate inputs rather than accuracy problems:

  • make_blobs 100000×8, manhattan — sklearn finds 9 clusters (DB 1.545), oneDAL 2 (DB 0.811). Under L1 with 8 features only ~96% of the core distances are distinct, so a large fraction of mutual-reachability edges collapse onto exactly max(core_i, core_j) and the MST tie order decides whether the blob pairs stay split. All three oneDAL backends agree with each other here, and at 40k rows oneDAL-brute vs sklearn-kd_tree is ARI 0.999954.
  • skin_segmentation 30000×3, brute — sklearn 1711/1728 clusters (DB 7.77/8.81) vs oneDAL 1561/1562 (DB 1.21/1.20). This dataset is uint8 RGB: the 30k subset has only 9154 distinct rows, and 17327 of the 30000 points have ≥5 exact duplicates, so with min_samples=5 their core distance is exactly 0 and the MST over them is massively non-unique. sklearn's own brute and kd_tree backends disagree with each other on this input for the same reason (1728 vs 1542 clusters). oneDAL's tie order happens to land on the far better-separated partition here (DB 1.20 vs 8.81).

Both are worth keeping in the configs precisely because they exercise tie handling.

One thing the configs surfaced is that brute is the weak method — it was the only group under 10x. Profiling the 25k×1024 and cifar cases pointed at the dense backend's MST stage, which is now being addressed upstream in oneDAL (Boruvka over a materialized N² mutual-reachability matrix does ~log₂N passes over the whole matrix where Prim's needs one). I'll follow up here with re-measured brute numbers once that lands so the config baseline reflects it.

Comment on lines +6 to +18
{
"dataset": "skin_segmentation",
"split_kwargs": { "train_size": 100000 },
"preprocessing_kwargs": { "normalize": "standard" }
},
{
"source": "make_blobs",
"generation_kwargs": {
"centers": 10,
"n_samples": 100000,
"n_features": 16,
"cluster_std": 2.0
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases are very similar. One idea with the benchmark is to track performance under different types of shapes and data situations. Please choose the examples carefully so as to maximize coverage.

Same for the two brute-force cases which are also similar.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 68ba517. Both pairs were picked for size, which is exactly the problem you point at, so each of the four now covers a different regime rather than a different row count:

method dataset regime it covers
kd_tree skin_segmentation 100000 x 3 real, discrete, duplicate-heavy RGB: low dimension, thousands of small clusters, a tie-dense mutual reachability graph
kd_tree make_blobs 100000 x 8, 100 centers (was 16 features / 10 centers) many well-separated clusters, so the cost moves into the condensed tree and the cluster selection instead of the neighbors search
brute mnist 20000 x 784 high dimension: the pairwise distance computation dominates
brute make_blobs 25000 x 4 (was 256) the opposite brute-force regime: the distance computation is negligible and the passes over the n^2 matrix dominate

Still 8 cases. Two notes on the choices:

  • The old tree-side blobs case (10 centers, 4/16/64 features) is already swept by configs/weekly/hdbscan.json, so it was a strict subset of the weekly coverage. 100 centers is not.
  • The two brute cases now sit at opposite ends of the same code path, which is the axis that actually decides where its time goes -- see the numbers below.

Verified end to end on the new config against stock scikit-learn (1 run each, 2 x Xeon 8480L, 224 threads):

method dataset shape sklearn oneDAL speedup clusters sk/oneDAL Davies-Bouldin sk/oneDAL
kd_tree skin_segmentation 100000 x 3 46942 ms 136 ms 345.8x 4847 / 4977 1.203 / 1.213
kd_tree make_blobs 100000 x 8 57754 ms 327 ms 176.7x 98 / 98 0.836 / 0.836
brute mnist 20000 x 784 3870 ms 748 ms 5.2x 11 / 13 2.472 / 2.274
brute make_blobs 25000 x 4 5478 ms 960 ms 5.7x 10 / 10 0.945 / 0.945

The four datasets in the regular config were picked for size, which left two
near-duplicate pairs. Each now covers a different regime:

* kd_tree, skin_segmentation 100000 x 3 -- real, discrete, duplicate-heavy, so
  low dimension with thousands of small clusters and a tie-dense mutual
  reachability graph.
* kd_tree, make_blobs 100000 x 8 with 100 centers (was 16 features, 10 centers)
  -- many well-separated clusters, which moves the cost into the condensed tree
  and the cluster selection instead of the neighbors search. Also no longer a
  subset of the weekly sweep, which covers 10 centers at 4 / 16 / 64 features.
* brute, mnist 20000 x 784 -- high dimension, where the pairwise distance
  computation dominates.
* brute, make_blobs 25000 x 4 (was 256 features) -- the opposite brute-force
  regime: the distance computation is negligible and the passes over the n^2
  matrix dominate.

Still 8 cases. Verified end to end against stock scikit-learn: 345.8x, 176.7x,
5.2x and 5.7x respectively, with matching cluster counts and Davies-Bouldin
scores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Alexandr-Solovev

Copy link
Copy Markdown
Author

What these configs measured

Since the point of the configs is to be used, here is the short report from the first full run of them, so the numbers are on the record next to the configs that produce them. Host: 2 x Intel Xeon Platinum 8480L (224 threads), scikit-learn 1.9.0, sklearnex.preview.cluster.HDBSCAN against stock sklearn.cluster.HDBSCAN.

90 cases run across configs/weekly/hdbscan.json and configs/experiments/hdbscan_parameters.json, 45 of which pair up between the two libraries:

method cases geomean min max
kd_tree 23 191.3x 24.4x 475.8x
ball_tree 11 96.3x 37.1x 187.9x
brute 11 4.1x 2.9x 6.8x
overall 45 63.1x 2.9x 475.8x

No case is slower than scikit-learn and 34 of 45 are at least 10x faster. Cluster counts and Davies-Bouldin scores agree throughout; where they differ it is in the last ~1% of the cluster count with matching quality metrics.

The configs found a real bottleneck

The brute-force column above was the outlier, and the configs/experiments/hdbscan_parameters.json metric sweep is what localized it. Three signals from it:

  • raising n_features from 64 to 1024 at 25000 rows -- 16x the GEMM work -- added only 14% to the fit time;
  • n scaling was cleanly quadratic;
  • euclidean was slower than manhattan and chebyshev at the same shape (1586 ms vs 1025 and 946 ms at 25000 x 64), even though euclidean gets its matrix from a single gemm while the other two run an explicit n^2 x d loop.

That last inversion can only come from work euclidean does and the others do not, which turned out to be a serial sweep over the whole n x n matrix in oneDAL's shared Euclidean distance primitive -- ~0.6 s on one thread at 25000 rows while 223 threads idled. Fixed in uxlfoundation/oneDAL#3750:

dataset metric shape before after speedup vs sklearn
skin_segmentation euclidean 30000 x 3 2366 ms 1187 ms 1.99x 3.84x -> 7.66x
make_blobs euclidean 25000 x 64 1586 ms 819 ms 1.94x 3.96x -> 7.67x
sensit euclidean 25000 x 100 1651 ms 869 ms 1.90x 3.29x -> 6.25x
cifar euclidean 15000 x 3072 1118 ms 804 ms 1.39x 2.90x -> 4.03x
make_blobs cosine 25000 x 64 783 ms 746 ms 1.05x 6.89x -> 7.23x
make_blobs manhattan 25000 x 64 1025 ms 989 ms 1.04x 5.77x -> 5.98x
make_blobs chebyshev 25000 x 64 946 ms 942 ms 1.00x 5.91x -> 5.94x

The three non-euclidean rows are the control: they never enter the code that changed. Cluster counts and Davies-Bouldin scores are bit-identical before and after in all ten cases. Restricted to euclidean, the brute-force geomean against scikit-learn moves from 3.67x to 6.70x.

The configs also flagged one case worth knowing about when reading these results: skin_segmentation at 30000 rows reports 1728 clusters from scikit-learn against 1562 from oneDAL, with Davies-Bouldin 8.806 vs 1.199. That is input degeneracy, not a defect -- the 30000-row subset has only 9154 distinct rows and 17327 points have at least five exact duplicates, so with min_samples=5 their core distance is exactly zero and the MST is not unique. scikit-learn's own brute and kd_tree backends disagree with each other on this input for the same reason.

Full write-up, including the correctness findings and the two optimizations that were measured and rejected, is in docs/reports/hdbscan_evaluation.rst on the oneDAL PR.

This branch has not been deployed

No deployments
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.

2 participants