Skip to content

Speed up the travelling salesman sort with bit-identical output#44

Open
bendichter wants to merge 1 commit into
MouseLand:mainfrom
bendichter:faster-tsp-sort
Open

Speed up the travelling salesman sort with bit-identical output#44
bendichter wants to merge 1 commit into
MouseLand:mainfrom
bendichter:faster-tsp-sort

Conversation

@bendichter

@bendichter bendichter commented Jul 16, 2026

Copy link
Copy Markdown

Summary

This makes the travelling salesman sort roughly three times faster on the default settings, and close to ten times faster on the high locality settings used for sequence finding, while producing bit-identical output. The change is confined to rastermap/sort.py and does not alter the algorithm, only how each candidate move is scored.

What Changed

new_correlation is called once per candidate move, and for each candidate it materialized a full permuted copy of the correlation matrix, summed it against BBt, then materialized a second copy to test the reversed ordering. The replacement computes the same sum directly from index arithmetic without building either matrix.

Two observations keep this exact. Reversing the moved segment's rows and columns is equivalent to reversing that segment of the permutation, which is an index relabeling rather than arithmetic. And BBt contains exact zeros, about half of it on the default path because it is upper triangular, so those terms can be skipped without changing the result.

Results

Measured on the dataset in tests/, on one 10 core machine:

Configuration Before After
default 6.5s 2.0s about 3x
locality=0.75, time_lag_window=5 11.8s 3.1s about 4x
locality=1.0, time_lag_window=10 10.7s 1.1s about 10x
grid_upsample=0 6.7s 2.0s about 3.5x
n_splits=3, n_clusters=50, sticky=False 1.6s 1.1s about 1.5x
n_splits=2, n_clusters=20, nc_splits=10 0.5s 0.5s none

Repeated runs on the same machine vary by a few percent to twenty percent, and the high locality case landed anywhere between nine and eleven times faster depending on the run.

The high locality case improves the most because BBt is then close to banded, so almost the entire sum is skipped rather than merely half of it. That is the regime the sequence finding settings target, so the optimization happens to help most where the sorting is slowest.

The small n_splits case is unchanged. It sorts 20 node matrices, where the per-candidate work is small and the allocations this removes did not cost much in the first place. That is the configuration in test_rastermap_splits, so anyone benchmarking that test specifically will not see a difference.

Verification

Output is bit-identical rather than approximately equal. The scripts are in this gist if you want to run the comparison yourself: record the sorting output on main, record it again on this branch, and diff the two recordings. It reports a difference of any size in any case, so it fails loudly rather than quietly passing on a tolerance.

I compared against the previous implementation in 52 cases with no mismatches:

  • Six configurations on the dataset in tests/, comparing embedding, isort, cc, U_nodes and X_embedding.
  • Eleven Rastermap configurations on synthetic data, covering locality, time_lag_window, n_splits, sticky, grid_upsample, mean_time, run_scaled_kmeans and time_bin.
  • 35 cases exercising traveling_salesman and matrix_matching directly, including locality values up to infinity, the circular basis, a dense user-supplied BBt, a BBt with scattered interior zeros, an asymmetric cc, and both n_skip settings.

The bit-exactness rests on numba's .sum() using a sequential float32 accumulator in memory order rather than numpy's pairwise summation. I confirmed this empirically on numba 0.60, where the two disagree on 187 of 200 random trials, so the hand-written loop reproduces numba's existing behavior and not numpy's.

What I Left Out

The n_splits path improves the least because its BBt_add is dense and no zeros can be skipped, so only the allocations are saved. A further factorization is available there, since corr_permuted_rows permutes rows but not columns and can therefore be precomputed as a single matrix product, reducing the per-candidate cost from O(n*m) to O(n). I left it out because it changes the floating point accumulation order, and with a greedy threshold of 1e-3 a small numerical difference can change which move is selected and therefore the final sort. That seemed worth discussing separately rather than folding into a change whose main claim is that nothing moves.

Note on Tooling

I used Claude Code to profile this and to write the patch. The reason I am comfortable proposing it is that the result is bit-identical, so the claim does not rest on trusting the tool: the comparison reproduces the previous implementation's output exactly on your own test data, and the diff is a mechanical restructuring of one function. Happy to walk through it, and happy to drop it if you would rather not carry a change of this shape.

The travelling salesman sort dominates rastermap's runtime. On the test
dataset in tests/ (569 neurons by 27680 timepoints), a default fit took
about 6.5 seconds, of which the PCA accounted for about 0.3 seconds and
essentially all of the remainder was the sort. The sort operates on the cluster
correlation matrix, so its cost depends on n_clusters rather than on the
size of the input, and it stays the bottleneck as datasets grow.

The cost was concentrated in new_correlation, which is called once per
candidate move. To score a candidate it built a full permuted copy of the
correlation matrix with cc[ishift][:, ishift], summed that against BBt,
and then built a second full copy to test the reversed ordering. That is
four O(n^2) passes and two allocations per candidate, and there are
thousands of candidates per iteration.

This computes the same quantity, sum over i,j of
cc[perm[i], perm[j]] * BBt[i,j], directly from index arithmetic, without
ever building the permuted matrix. Two properties make this exact rather
than approximate. First, reversing the moved segment's rows and columns
is the same operation as reversing that segment of the permutation, so
the reversed ordering costs one index reversal instead of a matrix copy.
Second, BBt contains exact zeros, roughly half of it on the default path
because it is upper triangular, and nearly all of it when locality is
high. Skipping those entries is bit-exact, because adding zero to a
float32 accumulator initialized to +0.0 is a no-op. The accumulation
order over the remaining terms is unchanged.

Measured on the test dataset, rounded because repeated runs vary by a few
percent to twenty percent:

  default                                  6.5s -> 2.0s   about 3x
  locality=0.75, time_lag_window=5        11.8s -> 3.1s   about 4x
  locality=1.0, time_lag_window=10        10.7s -> 1.1s   about 10x
  grid_upsample=0                          6.7s -> 2.0s   about 3.5x
  n_splits=3, n_clusters=50, sticky=False  1.6s -> 1.1s   about 1.5x
  n_splits=2, n_clusters=20, nc_splits=10  0.5s -> 0.5s   none

The gain is largest when locality is high, because BBt is then close to
banded and almost the whole sum is skipped. It is negligible for small
n_splits configurations, where the sorted matrices are small enough that
the removed allocations did not cost much to begin with.

Output is bit-identical, not merely close. Verified in 52 comparisons
against the previous implementation with no mismatches: six
configurations on the test dataset, eleven Rastermap configurations on
synthetic data, and 35 cases exercising traveling_salesman and
matrix_matching directly, including locality of infinity, the circular
basis, a dense user-supplied BBt, a BBt with scattered interior zeros,
and an asymmetric cc. In every case the embedding, the sort order, the
sorted correlation matrix and the cluster centers match exactly.

There is a further factorization available for the n_splits path, since
corr_permuted_rows permutes rows but not columns, but it changes the
floating point accumulation order and so is left out here.

One precondition worth recording: skipping the zeros assumes cc is
finite, since 0.0 * inf is nan. That holds for a correlation matrix and
for the test data, but it is not checked.
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.

1 participant