Skip to content

Commit 3bc75c9

Browse files
committed
feat(flas): add fast linear assignment solver for better memory layouts
1 parent 286a769 commit 3bc75c9

8 files changed

Lines changed: 2130 additions & 0 deletions

File tree

cpp/benchmark/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ add_executable(deglib_groundtruth ${PROJECT_SOURCE_DIR}/src/deglib_groundtruth.c
3030
target_link_libraries(deglib_groundtruth PUBLIC DEG_LIB compile-options
3131
fmt::fmt-header-only OpenMP::OpenMP_CXX)
3232
target_compile_definitions(deglib_groundtruth PUBLIC "DATA_PATH=\"${DATA_PATH}\"")
33+
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
#ifndef FLAS_FAST_LINEAR_ASSIGNMENT_SORTER_H
2+
#define FLAS_FAST_LINEAR_ASSIGNMENT_SORTER_H
3+
4+
#include <algorithm>
5+
#include <cmath>
6+
#include <cstring>
7+
#include <functional>
8+
#include <random>
9+
#include <span>
10+
11+
#include <distances.h>
12+
#include <distance/fp32_l2.h>
13+
#include <distance/evp_inner_product.h>
14+
15+
#include "junker_volgenant_solver.h"
16+
17+
namespace flas {
18+
19+
// Stores element ID and a pointer to its feature vector for tracking during sorting.
20+
struct MapField {
21+
int id;
22+
const float *feature;
23+
};
24+
25+
// Initializes a single MapField with element ID and feature vector pointer.
26+
inline void init_map_field(MapField &map_field, const int id, const float *const feature) {
27+
map_field.id = id;
28+
map_field.feature = feature;
29+
}
30+
31+
// Fills a span of MapFields with sequential IDs (0..N-1) and pointers to contiguous features.
32+
inline void init_map_fields(std::span<MapField> map_fields, const float *features, int dim) {
33+
const size_t dim_sz = static_cast<size_t>(dim);
34+
const size_t count = map_fields.size();
35+
for (size_t i = 0; i < count; ++i) {
36+
map_fields[i].id = static_cast<int>(i);
37+
map_fields[i].feature = features + i * dim_sz;
38+
}
39+
}
40+
41+
// Helper: Creates a std::vector<MapField> pre-initialized with IDs (0..N-1) and feature pointers.
42+
inline std::vector<MapField> make_map_fields(const float *features, int count, int dim) {
43+
std::vector<MapField> map_fields(static_cast<size_t>(count));
44+
init_map_fields(map_fields, features, dim);
45+
return map_fields;
46+
}
47+
48+
using RandomEngine = std::mt19937;
49+
constexpr int QUANT = 256;
50+
51+
enum class FlasMetric { L2, InnerProduct };
52+
53+
// Configuration parameters for the FLAS 1D sorting algorithm.
54+
struct FlasSettings {
55+
float initial_radius_factor = 0.5f;
56+
float radius_decay = 0.9f;
57+
float radius_end = 1.0f;
58+
int num_filters = 1;
59+
int max_swap_positions = 9;
60+
float sample_factor = 1.0f;
61+
FlasMetric metric = FlasMetric::L2;
62+
};
63+
64+
// Scratch buffers for candidate swap positions, quantized distance LUT, and JV solver state.
65+
class SwapBuffers {
66+
public:
67+
std::vector<int> swap_positions;
68+
std::vector<int> swap_indices;
69+
std::vector<MapField> swapped_elements;
70+
std::vector<const float *> fvs;
71+
std::vector<const float *> som_fvs;
72+
std::vector<int> dist_lut;
73+
std::vector<float> dist_lut_f;
74+
JVScratch jv_scratch;
75+
76+
explicit SwapBuffers(int num_swap_positions = 0, int count = 0) {
77+
if (num_swap_positions > 0 || count > 0) {
78+
resize(static_cast<size_t>(num_swap_positions), static_cast<size_t>(count));
79+
}
80+
}
81+
82+
// Resizes swap candidate and LUT buffers to `n` positions.
83+
void resize(size_t n, size_t count = 0) {
84+
swap_positions.resize(n);
85+
swapped_elements.resize(n);
86+
fvs.resize(n);
87+
som_fvs.resize(n);
88+
dist_lut.resize(n * n);
89+
dist_lut_f.resize(n * n);
90+
jv_scratch.init(static_cast<int>(n));
91+
if (count > 0) {
92+
swap_indices.resize(count);
93+
}
94+
}
95+
96+
int num_swap_positions() const noexcept {
97+
return static_cast<int>(swap_positions.size());
98+
}
99+
};
100+
101+
// Problem context binding map fields span, dimensions, RNG stream, and distance metric.
102+
struct FlasContext {
103+
std::span<MapField> map_fields;
104+
int count;
105+
int dim;
106+
107+
RandomEngine &rng;
108+
FlasMetric metric;
109+
deglib::DISTFUNC<float> dist_func;
110+
111+
FlasContext(std::span<MapField> map_fields_, int count_, int dim_, RandomEngine &rng_, FlasMetric metric_)
112+
: map_fields(map_fields_), count(count_), dim(dim_), rng(rng_), metric(metric_) {
113+
114+
if (metric == FlasMetric::InnerProduct) {
115+
dist_func = deglib::to_dist_func(deglib::to_flat_variant(deglib::distances::fp32_ip::select_dist(dim)));
116+
} else {
117+
dist_func = deglib::to_dist_func(deglib::to_flat_variant(deglib::distances::fp32_l2::select_dist(dim)));
118+
}
119+
}
120+
121+
FlasContext(MapField *map_fields_, int count_, int dim_, RandomEngine &rng_, FlasMetric metric_)
122+
: FlasContext(std::span<MapField>(map_fields_, static_cast<size_t>(count_)), count_, dim_, rng_, metric_) {}
123+
124+
FlasContext(const FlasContext&) = delete;
125+
FlasContext& operator=(const FlasContext&) = delete;
126+
};
127+
128+
// Manages SOM buffer and moving-average filter scratch memory (Move-safe, Rule of Zero).
129+
class SomGrid {
130+
public:
131+
std::vector<float> som_buf;
132+
size_t som_offset = 0;
133+
134+
std::vector<float> filtered_som_buf; // Buffer holding filtered SOM values (count * dim)
135+
std::vector<float> window_sum; // Accumulator for sliding window filter (dim)
136+
137+
explicit SomGrid(int count, int dim) {
138+
size_t max_ext = static_cast<size_t>(count / 2);
139+
som_offset = max_ext * static_cast<size_t>(dim);
140+
som_buf.resize(static_cast<size_t>(count + 2 * max_ext) * static_cast<size_t>(dim));
141+
filtered_som_buf.resize(static_cast<size_t>(count) * static_cast<size_t>(dim));
142+
window_sum.resize(static_cast<size_t>(dim));
143+
}
144+
145+
inline float* som() noexcept { return som_buf.data() + som_offset; }
146+
inline const float* som() const noexcept { return som_buf.data() + som_offset; }
147+
148+
inline float* row(int i, int dim) noexcept { return som() + i * dim; }
149+
inline const float* row(int i, int dim) const noexcept { return som() + i * dim; }
150+
151+
// Mirrors boundary cells for circular/clamped window filtering.
152+
inline void apply_mirror_padding(int ext, int count, int dim) noexcept {
153+
float *s = som();
154+
for (int i = 0; i < ext; i++) {
155+
std::memcpy(&s[(-1 - i) * dim], &s[(i + 1) * dim], dim * sizeof(float));
156+
std::memcpy(&s[(count + i) * dim], &s[(count - 2 - i) * dim], dim * sizeof(float));
157+
}
158+
}
159+
};
160+
161+
// Randomly shuffles the elements in a span in-place using the provided RNG.
162+
inline void shuffle_array(std::span<int> data, RandomEngine &rng) {
163+
std::shuffle(data.begin(), data.end(), rng);
164+
}
165+
166+
// Phase "copy": Copies current MapField feature vectors into the SOM grid buffer.
167+
inline void copy_feature_vectors_to_som(const FlasContext &ctx, SomGrid &grid) {
168+
for (int i = 0; i < ctx.count; i++) {
169+
const MapField &map_field = ctx.map_fields[i];
170+
std::copy_n(map_field.feature, ctx.dim, grid.row(i, ctx.dim));
171+
}
172+
}
173+
174+
// Phase "filter": Applies a 1D sliding-window moving-average filter over a 2*radius+1 window.
175+
inline void filter_weighted_som_1d(int radius, const FlasContext &ctx, SomGrid &grid) {
176+
if (ctx.count <= 1 || radius <= 0)
177+
return;
178+
179+
int filter_size = 2 * radius + 1;
180+
int ext = filter_size / 2;
181+
int size = ctx.count;
182+
int dims = ctx.dim;
183+
const float inv_filter_size = 1.0f / static_cast<float>(filter_size);
184+
185+
float *window_sum = grid.window_sum.data();
186+
187+
// Apply boundary mirror padding to handle edge elements
188+
grid.apply_mirror_padding(ext, size, dims);
189+
190+
// Initialize sliding window sum for the first window [-ext, ext]
191+
const float *base_ptr = grid.row(-ext, dims);
192+
std::fill_n(window_sum, dims, 0.0f);
193+
for (int i = 0; i < filter_size; i++) {
194+
const float *cell = base_ptr + i * dims;
195+
for (int d = 0; d < dims; d++)
196+
window_sum[d] += cell[d];
197+
}
198+
199+
// Store normalized average for index 0
200+
for (int d = 0; d < dims; d++)
201+
grid.filtered_som_buf[d] = window_sum[d] * inv_filter_size;
202+
203+
// Slide window across remaining array elements (subtract outgoing cell, add incoming cell)
204+
for (int i = 1; i < size; i++) {
205+
const float *left_cell = base_ptr + (i - 1) * dims;
206+
const float *right_cell = base_ptr + (i - 1 + filter_size) * dims;
207+
208+
for (int d = 0; d < dims; d++) {
209+
window_sum[d] += right_cell[d] - left_cell[d];
210+
grid.filtered_som_buf[i * dims + d] = window_sum[d] * inv_filter_size;
211+
}
212+
}
213+
214+
// Copy filtered SOM back into main SOM buffer
215+
std::copy_n(grid.filtered_som_buf.data(), ctx.count * ctx.dim, grid.som());
216+
}
217+
218+
// Calculates pairwise distance matrix between swap candidates and quantizes to [0, QUANT].
219+
inline void calc_dist_lut_int(const FlasContext &ctx, SwapBuffers &swaps, int num_swaps) {
220+
float max_val = 0.0f;
221+
const size_t dim_sz = static_cast<size_t>(ctx.dim);
222+
auto dist_func = ctx.dist_func;
223+
224+
// Compute exact floating-point distance matrix between candidates and SOM cells
225+
for (int i = 0; i < num_swaps; i++) {
226+
for (int j = 0; j < num_swaps; j++) {
227+
float val = dist_func(swaps.fvs[i], swaps.som_fvs[j], &dim_sz);
228+
swaps.dist_lut_f[i * num_swaps + j] = val;
229+
if (val > max_val)
230+
max_val = val;
231+
}
232+
}
233+
234+
// Normalize and quantize distances to integer range [0, 256] for fast JV solver processing
235+
if (max_val < 1e-10f) max_val = 1.0f;
236+
const float inv_max_val = static_cast<float>(QUANT) / max_val;
237+
for (int i = 0; i < num_swaps; i++) {
238+
for (int j = 0; j < num_swaps; j++) {
239+
swaps.dist_lut[i * num_swaps + j] = static_cast<int>(std::lround(swaps.dist_lut_f[i * num_swaps + j] * inv_max_val));
240+
}
241+
}
242+
}
243+
244+
// Solves linear assignment for chosen swap positions using JV solver and updates element order.
245+
inline void do_swaps(const FlasContext &ctx, SomGrid &grid, SwapBuffers &swaps, int num_swaps) {
246+
if (num_swaps == 0) return;
247+
248+
// Snapshot candidate MapFields and feature/SOM pointers
249+
for (int i = 0; i < num_swaps; i++) {
250+
int swap_position = swaps.swap_positions[i];
251+
MapField &swapped_element = ctx.map_fields[swap_position];
252+
swaps.swapped_elements[i] = swapped_element;
253+
swaps.fvs[i] = swapped_element.feature;
254+
swaps.som_fvs[i] = grid.row(swap_position, ctx.dim);
255+
}
256+
257+
// Calculate distance matrix and solve optimal linear assignment problem via JV algorithm
258+
calc_dist_lut_int(ctx, swaps, num_swaps);
259+
compute_assignment(swaps.dist_lut.data(), num_swaps, swaps.jv_scratch);
260+
const int *permutation = swaps.jv_scratch.perm();
261+
262+
// Write optimal permutation back into map_fields
263+
for (int i = 0; i < num_swaps; i++) {
264+
ctx.map_fields[swaps.swap_positions[permutation[i]]] = swaps.swapped_elements[i];
265+
}
266+
}
267+
268+
// Selects candidate swap positions within a localized random window.
269+
inline int find_swap_positions_1d(const FlasContext &ctx, SwapBuffers &swaps, std::span<const int> swap_indices, int num_swap_indices) {
270+
// Pick random center x0 and clamp window range [x_start, x_start + num_swap_indices]
271+
std::uniform_int_distribution<int> pos_dist(0, ctx.count - 1);
272+
int x0 = pos_dist(ctx.rng);
273+
274+
int x_start = std::max(0, std::min(x0 - num_swap_indices / 2, ctx.count - num_swap_indices));
275+
276+
// Determine starting index in shuffled offsets to select up to max_sp candidate positions
277+
int max_sp = swaps.num_swap_positions();
278+
int start_index = 0;
279+
if (num_swap_indices > max_sp) {
280+
std::uniform_int_distribution<int> index_dist(0, num_swap_indices - max_sp - 1);
281+
start_index = index_dist(ctx.rng);
282+
}
283+
284+
// Collect candidate swap positions
285+
int num_swap_positions = 0;
286+
for (int j = start_index; j < num_swap_indices && num_swap_positions < max_sp; j++) {
287+
int dx = swap_indices[j];
288+
int pos = std::min(ctx.count - 1, std::max(0, x_start + dx));
289+
swaps.swap_positions[num_swap_positions++] = pos;
290+
}
291+
return num_swap_positions;
292+
}
293+
294+
// Runs one iteration of localized random swap probes for the given radius.
295+
inline void check_random_swaps_1d(const FlasContext &ctx, SomGrid &grid, SwapBuffers &swaps, int radius, float sample_factor) {
296+
int max_sp = swaps.num_swap_positions();
297+
if (max_sp == 0)
298+
return;
299+
300+
// Determine active swap window size based on current neighborhood radius
301+
int num_swap_indices = std::min(2 * radius + 1, ctx.count);
302+
while (num_swap_indices < max_sp && num_swap_indices < ctx.count) {
303+
num_swap_indices++;
304+
}
305+
306+
// Resize index buffer if needed and initialize indices [0..num_swap_indices-1]
307+
if (swaps.swap_indices.size() < static_cast<size_t>(num_swap_indices)) {
308+
swaps.swap_indices.resize(static_cast<size_t>(num_swap_indices));
309+
}
310+
311+
for (int i = 0; i < num_swap_indices; i++)
312+
swaps.swap_indices[i] = i;
313+
314+
// Shuffle active indices range to select randomized candidate subsets
315+
std::span<int> active_indices(swaps.swap_indices.data(), static_cast<size_t>(num_swap_indices));
316+
shuffle_array(active_indices, ctx.rng);
317+
318+
// Perform multiple swap probes according to sample_factor
319+
int num_swap_tries = std::max(1, static_cast<int>(sample_factor * static_cast<float>(ctx.count) / static_cast<float>(max_sp)));
320+
for (int n = 0; n < num_swap_tries; n++) {
321+
int num_swaps = find_swap_positions_1d(ctx, swaps, active_indices, num_swap_indices);
322+
do_swaps(ctx, grid, swaps, num_swaps);
323+
}
324+
}
325+
326+
// Main 1D FLAS sorter: repeatedly copies to SOM, applies moving-average filter, and performs random swaps.
327+
inline void do_sorting_1d(
328+
std::span<MapField> map_fields, int dim, const FlasSettings &settings, RandomEngine &rng,
329+
const std::function<bool(float)>& progress_callback
330+
) {
331+
int count = static_cast<int>(map_fields.size());
332+
if (count <= 0) return;
333+
float rad = static_cast<float>(count) * settings.initial_radius_factor;
334+
335+
const int num_iterations = static_cast<int>(ceil(-log(rad / settings.radius_end) / log(settings.radius_decay)));
336+
int iteration_counter = 0;
337+
if (progress_callback && progress_callback(0.f))
338+
return;
339+
340+
FlasContext ctx(map_fields, count, dim, rng, settings.metric);
341+
SomGrid grid(count, dim);
342+
SwapBuffers swaps(std::min(count, settings.max_swap_positions), count);
343+
344+
do {
345+
// 1. Copy current feature vectors to SOM grid
346+
copy_feature_vectors_to_som(ctx, grid);
347+
348+
int radius = std::max(1, static_cast<int>(std::round(rad)));
349+
int radius_1d = std::max(1, std::min(count / 2, radius));
350+
rad *= settings.radius_decay;
351+
352+
// 2. Apply 1D sliding-window moving-average filter
353+
for (int i = 0; i < settings.num_filters; i++)
354+
filter_weighted_som_1d(radius_1d, ctx, grid);
355+
356+
// 3. Perform localized linear assignment swaps
357+
check_random_swaps_1d(ctx, grid, swaps, radius, settings.sample_factor);
358+
359+
// 4. Report progress and evaluate early termination callback
360+
iteration_counter++;
361+
float progress = static_cast<float>(iteration_counter) / static_cast<float>(num_iterations);
362+
if (progress_callback && progress_callback(progress))
363+
break;
364+
} while (rad > settings.radius_end);
365+
}
366+
367+
} // namespace flas
368+
369+
#endif // FLAS_FAST_LINEAR_ASSIGNMENT_SORTER_H

0 commit comments

Comments
 (0)