|
| 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