Fix O(n²) compress on duplicate/low-cardinality input (MOD-17228) - #42
Fix O(n²) compress on duplicate/low-cardinality input (MOD-17228)#42fcostaoliveira wants to merge 8 commits into
Conversation
td_qsort used a single central pivot and recursed both partitions. On a run of equal keys the partition peels one element per level -> O(n^2) comparisons and O(n) recursion depth. Duplicate/low-cardinality input is t-digest's common case (it summarizes streams of repeated measurements), so this is not an adversarial edge: a compress over N buffered equal values is quadratic. Because compress runs inline on the calling thread, a large such compress stalls that thread for the full O(n^2). Measured through the RedisBloom TDIGEST commands on Redis 8.8.1: 430k identical values -> a single TDIGEST.QUANTILE (which triggers compress) blocks the server ~98s. Replace the sort with 3-way (Dutch-national-flag) partitioning plus tail-recursion elimination: equal keys collapse in a single pass (O(n log n) even for all-equal input) and recursing only the smaller side bounds the stack to O(log n). Output is identical to the previous sort. Single compress of N identical values, before -> after: 50k: 1,238 ms -> 1.0 ms 100k: 4,994 ms -> 2.0 ms 200k: 20,069 ms -> 3.2 ms 5M: (hours) -> 80 ms Verified: existing unit suite passes unchanged, plus a new test (all-equal / ascending / descending, 50k each) asserting sorted centroids and correct quantiles; 20,000 randomized parallel-array sort trials; ASan/UBSan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gabsow
left a comment
There was a problem hiding this comment.
I found one blocking issue and two coverage/compatibility gaps.
[P1] The deterministic midpoint pivot still permits O(n²) input.
Three-way partitioning fixes the all-equal/low-cardinality trigger, and tail-recursion elimination bounds stack depth, but the sort still selects means[lo + (hi - lo) / 2] deterministically. A client can provide a static permutation of distinct values that makes each selected pivot the smallest remaining value, so every partition removes only one element and the runtime remains quadratic.
I reproduced this against this PR's compiled head:
| Values | td_compress CPU time |
|---|---|
| 10,000 | 0.062 s |
| 20,000 | 0.207 s |
| 40,000 | 0.807 s |
| 80,000 | 3.087 s |
The near-4× increase on doubling confirms quadratic scaling; the same curve projects to roughly 89 seconds at 430k values on this machine. This narrows MOD-17228's trigger but does not eliminate the remotely-triggerable stall. Please use a guaranteed O(n log n) strategy, such as introsort with a heapsort fallback, while continuing to permute means and weights together.
[P2] The added regression passes with the old sorter unchanged.
td_new(200) has capacity 1,210, so the 50k td_add calls auto-compress in small batches rather than exercising one 50k-element partition. I linked the PR's new test file against the pre-fix library: all 21 tests / 5,152,257 assertions passed. Please add a regression that observes the complexity bound (for example, test-only comparison instrumentation), rather than relying only on correctness assertions or timing.
[P2] The “output is identical” claim does not hold for weighted duplicates.
The new equal-key permutation changes how weighted centroids are formed. In one deterministic weighted-duplicate case, the previous sorter produced 8 centroids and this PR produced 7; CDF(0) changed from 0.07999 to 0.03749, and the 0.9 quantile changed from 3.9517 to 4. If exact compatibility is not required, please remove the claim and add weighted-duplicate accuracy coverage; otherwise the tie behavior needs to be preserved.
The normal unit suite and an ASan/UBSan build pass locally, and this combines cleanly with #41.
Address review on RedisBloom#42: [P1] A deterministic midpoint pivot still permits a crafted DISTINCT-value permutation to force O(n^2). Replace the 3-way quicksort with an introsort: 3-way (Dutch-flag) partitioning for duplicate runs, a median-of-three pivot, and a recursion-depth limit that falls back to heapsort -- guaranteeing O(n log n) worst case and O(log n) stack for any input. Small ranges finish with insertion sort. [P2] Add a complexity regression that OBSERVES the bound instead of relying on correctness/timing: build the library with TD_INSTRUMENT_SORT (a key-comparison counter, zero-cost otherwise), force ONE large compress over an adversarial midpoint-killer permutation, and assert the comparison count stays within an O(n log n) bound (a quadratic sort would need ~n^2/2). New ctest target `td_sort_complexity_test`. Also fixes the cmake unit-test build so ctest runs (minunit needs _POSIX_C_SOURCE, hidden by -std=c99). [P2] The "output is identical" claim was wrong for weighted duplicates: the equal-key order differs, changing how weighted centroids merge. Drop the claim (the sort stays within t-digest's accuracy guarantee) and add `test_weighted_duplicates_accuracy` asserting correct quantiles/CDF/min/max/size. Verified: 22 unit tests / 5.15M assertions + complexity test pass via ctest; ASan+UBSan clean; introsort O(n log n) confirmed on ascending/descending/ all-equal/organ-pipe/center/sawtooth/random over 20k randomized parallel-array trials; clang-format OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — all three addressed in 71c372b. [P1] Guaranteed O(n log n). Replaced the 3-way quicksort with an introsort: 3-way (Dutch-flag) partitioning for equal-key runs, a median-of-three pivot, and a recursion-depth limit that falls back to heapsort, so no crafted permutation (distinct or not) can force quadratic time; stack is O(log n). Small ranges finish with insertion sort. Confirmed O(n log n) on ascending/descending/all-equal/organ-pipe/center/sawtooth/random and 20k randomized parallel-array trials. [P2] Complexity regression that observes the bound. New ctest target [P2] "Output identical" claim dropped. You're right — the equal-key order differs, changing weighted-centroid merges, so it is not centroid-for-centroid identical. Removed the claim (results stay within t-digest's accuracy guarantee) and added 22 unit tests / 5.15M assertions + the complexity test pass via ctest; ASan+UBSan clean. |
- td_sort_complexity_test: free the histogram on the early error-return paths (S3584 potential leak of 'h'). - td_test: use an integer loop counter in the CDF sweep instead of a float (S2193 "do not use a counter of type float"). ctest green; LSan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gabsow
left a comment
There was a problem hiding this comment.
Four blocking findings remain after re-review; details are inline.
| * (this is exactly the distinct-permutation weakness of a fixed-position pivot). | ||
| * Introsort's median-of-three pivot plus heapsort fallback keeps it O(n log n). | ||
| */ | ||
| #define TD_INSTRUMENT_SORT |
There was a problem hiding this comment.
TD_INSTRUMENT_SORT is also defined by target_compile_definitions() in tests/CMakeLists.txt. CMake's -DTD_INSTRUMENT_SORT defines it as 1, then this line redefines it with an empty replacement list. GCC/Clang diagnose that redefinition, and the project's ENABLE_SANITIZERS configuration promotes it to an error via -Werror, so the sanitizer CMake build fails. Please remove one of the two definitions.
There was a problem hiding this comment.
Fixed in c5b63b4 — guarded the in-file define with #ifndef TD_INSTRUMENT_SORT, so CMake's -DTD_INSTRUMENT_SORT=1 no longer collides with it. Verified with a clean -Werror build under -DENABLE_SANITIZERS=ON.
| } | ||
| const int mid = lo + (hi - lo) / 2; | ||
| a[mid] = (double)(*next)++; | ||
| fill_midpoint_killer(a, lo, mid - 1, next); |
There was a problem hiding this comment.
This is not actually a killer for the old in-place midpoint/Lomuto sorter: partition swaps destroy the recursively assigned midpoint positions. Instrumenting the exact pre-PR sorter on this generated input at n=100000 gives only 1,700,396 comparisons, far below this test's 83,048,202 bound, so the old quadratic implementation would pass the claimed regression. Please use the validated adversarial permutation from the original reproduction, or explicitly instrument/assert the heapsort fallback (ideally with multi-size scaling) so removing the worst-case guarantee makes the test fail.
There was a problem hiding this comment.
Good catch — confirmed the midpoint-killer is not adversarial for the pre-PR Lomuto sort (measured ~1.7M comparisons at n=1e5, well under the old bound). Replaced it in c5b63b4 with two measured, multi-size regressions:
- Part A — the actual MOD-17228 vector (N identical values). The pre-PR central-pivot Lomuto sort does exactly n²/2 comparisons here (measured 5.0e9 at n=1e5); the 3-way partition does exactly 2n. The test asserts a strict linear bound (8n), which a revert to any sort without equal-run handling misses by ~3 orders of magnitude.
- Part B — the heapsort fallback, driven directly (measured ~1.78·n·log₂n), asserting an O(n log n) bound (4·n·log₂n) with the normalized ratio held flat (1.735–1.776) across n=25k…200k, so weakening/removing the fallback fails here.
All constants are measured, not assumed.
| // index `i`), keyed on means with weights moved in lock-step. | ||
| static void td_sift_down(double *means, long long *weights, int lo, int i, int n) { | ||
| for (;;) { | ||
| int child = 2 * i + 1; |
There was a problem hiding this comment.
This can overflow signed int for a valid large heap. With n > INT_MAX/2, sift-down can move i to a leaf above (INT_MAX-1)/2; the next 2 * i + 1 is undefined behavior and can become a negative/out-of-bounds index. #41 permits capacities approaching INT_MAX, so this is within the accepted range on large-memory hosts. Guard leaf indices before multiplying (for example, stop when i >= n / 2) or use checked wider/unsigned indices.
There was a problem hiding this comment.
Fixed in c5b63b4 — td_sift_down now loops while (i < n/2) and forms 2*i+1 only for internal nodes. For i < n/2, 2*i < n so 2*i+1 <= n and cannot overflow signed int, even at capacities near INT_MAX. Heapsort comparison counts are unchanged (same algorithm), verified by the Part-B measurements.
| td_qsort(means, weights, start, new_pivot_idx - 1); | ||
| depth_limit--; | ||
| const int mid = lo + (hi - lo) / 2; | ||
| const double pivot = td_median3(means, weights, lo, mid, hi); |
There was a problem hiding this comment.
td_add() currently accepts and stores non-finite means, but this sorter has no total ordering for NAN. If the pivot is NaN, both v < pivot and v > pivot are false, so every value is classified into the equal band and the range can remain unsorted; the insertion-sort path has the same issue ([2.0, NAN, 1.0] remains unordered). td_compress() then consumes nodes whose sorted invariant is false. Please either reject !isfinite(mean) in td_add() before mutation or define/document a total ordering and add NaN/±Inf coverage.
There was a problem hiding this comment.
Fixed in c5b63b4 — td_add() now rejects NaN with EINVAL before any mutation (matching the reference t-digest, which rejects NaN in add()), so the sort's total-order assumption holds. ±Inf remain accepted (they have a valid ordering and become min/max). Documented in tdigest.h; added test_add_nonfinite covering NaN-reject, ±Inf-accept, and the sorted invariant after a compress with infinities present.
…ssion Address gabsow's second review on RedisBloom#42: - td_sift_down: `2*i + 1` could overflow signed int for a valid large heap (RedisBloom#41 permits capacities near INT_MAX). Test leaf-ness (`i < n/2`) before forming the child index; for i < n/2, 2*i+1 <= n and cannot overflow. - td_add: reject NaN (EINVAL) before mutating. The centroid sort assumes a total order over means, but NaN compares false to everything and would leave a partition unsorted, breaking td_compress()'s invariant. Matches the reference t-digest; +/-Inf remain accepted (valid ordering). Documented in tdigest.h; added test_add_nonfinite covering NaN-reject / Inf-accept / sorted. - td_sort_complexity_test: the distinct "midpoint killer" was not adversarial for the pre-PR Lomuto sort (partition swaps destroy the midpoint layout; ~1.7M comparisons, under the old bound), so a quadratic revert would pass it. Replace with two measured, multi-size regressions: Part A sorts N identical values -- the actual MOD-17228 vector, where the pre-PR sort is exactly n^2/2 and the 3-way partition is exactly 2n -- and asserts a linear bound; Part B drives the heapsort fallback directly (~1.78 n log2 n) and asserts an O(n log n) bound with a flat normalized ratio across sizes. - Fix the macro redefinition: the file `#define TD_INSTRUMENT_SORT` clashed with CMake's `-DTD_INSTRUMENT_SORT=1`, which -Werror (ENABLE_SANITIZERS) rejects. Guard it with `#ifndef`. - tests/CMakeLists.txt: use CMAKE_CURRENT_LIST_DIR/../src so the include path survives add_subdirectory() consumption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed c5b63b4 addressing all four review comments:
Also fixed the Verified locally: clean |
- Move the sort's comparison counter into an inlined td_key_lt() helper so the boolean conditions no longer carry a comma-operator side effect in the && RHS (S912). Inlines to a plain `a < b` when TD_INSTRUMENT_SORT is off; comparison counts are byte-for-byte identical (verified: all-equal 2n, heapsort ~1.78 n log2 n unchanged). - Split multi-declarations into dedicated statements (S1659). - calloc() the complexity test's scratch buffers so the analyzer no longer traces an uninitialized read through td_heap_sort -> swap_l (S836 BUG, the sole new_reliability_rating failure). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Follow-up a5a8f3c clears the SonarCloud reliability gate: moved the sort's comparison counter into an inlined |
gabsow
left a comment
There was a problem hiding this comment.
Two blockers remain after re-review; details are inline.
| // Reject NaN before any mutation: the centroid sort assumes a total order over the stored | ||
| // means, but NaN compares false to everything, so a NaN key would leave a partition | ||
| // unsorted and violate td_compress()'s sorted invariant. This matches the reference | ||
| // t-digest, which rejects NaN in add(). +/-Inf are permitted -- they have a valid total |
There was a problem hiding this comment.
+/-Inf has a total order for sorting, but it is not closed under the centroid-merge arithmetic. Reproducer against a5a8f3c: create td_new(200), add INFINITY with weight 1 one hundred times, then call td_compress(); this produces a NaN centroid (I observed index 17 of 61), because the merge computes delta = Inf - Inf and then adds NaN at lines 770–772. A later sort therefore loses its ordering invariant again even though direct NaN input is rejected. Please either reject all non-finite means or explicitly handle equal infinite centroid merges, and add repeated +/-Inf coverage.
There was a problem hiding this comment.
Confirmed and fixed in d8cd91d. Reproduced exactly (100x +Inf -> 26 NaN centroids, first at index 17/61): the merge computes delta = Inf - Inf = NaN. td_add() now rejects all non-finite means (!isfinite), not just NaN, so every stored mean stays finite and the merge can never manufacture a NaN. Header updated; test_add_nonfinite now asserts NaN/+Inf/-Inf are all rejected and that the repeated-+Inf reproducer leaves the digest empty.
| w[i] = 1; | ||
| } | ||
| td_sort_comparisons = 0; | ||
| td_heap_sort(m, w, 0, n - 1); |
There was a problem hiding this comment.
This exercises td_heap_sort() in isolation, not the introsort fallback integration. I removed the depth_limit == 0 fallback branch locally while leaving this helper intact; both Part A and Part B still passed every size and bound. Therefore the test does not catch removal of the claimed worst-case O(n log n) guarantee. Please drive a validated adversarial input through the full td_qsort() path, instrument/assert that the fallback is actually reached, and keep the multi-size comparison bound.
There was a problem hiding this comment.
Good catch — you're right that the isolated td_heap_sort() test could not detect fallback removal. Rewrote Part B in d8cd91d to drive a validated adversarial input through the full td_qsort() path: it generates a median-of-three killer with McIlroy's quicksort adversary run against a faithful mirror of this introsort's comparison sequence, feeds it through the real td_compress(), and asserts across sizes that (a) the fallback is actually reached (new td_sort_heap_fallbacks counter, measured 1 per size), (b) comparisons stay O(n log n) (~5.6 n·log₂n), and (c) the ratio is flat. I verified both failure modes: driving the same killer through a no-fallback quicksort (depth_limit=INT_MAX) is quadratic — 5.0e9 comparisons at n=1e5 (~3000 n·log₂n) — so removing the fallback fails both the fallback-reached assert and the bound. Part B uses modest sizes because killer generation is itself O(n²).
Address gabsow's third review on RedisBloom#42: - td_add: reject all non-finite means, not just NaN. +/-Inf sorts fine but is not closed under the centroid merge: merging two equal infinities computes delta = Inf - Inf = NaN, poisoning a centroid mean and re-breaking the sort invariant (reproduced: 100x +Inf -> 26 NaN centroids). Rejecting !isfinite at ingest keeps every stored mean finite. Header + test_add_nonfinite updated to assert +/-Inf are rejected and the repeated-+Inf reproducer stays empty. - Complexity test Part B rewritten. The old helper drove td_heap_sort() in isolation, so removing the depth-limit fallback did not fail it. It now generates a median-of-three killer with McIlroy's quicksort adversary (run against a faithful mirror of this introsort's comparison sequence), feeds it through the real td_compress()/td_qsort(), and asserts across sizes that the heapsort fallback is actually reached (new td_sort_heap_fallbacks counter), the comparison count stays O(n log n) (measured ~5.7 n log2 n; the same input without the fallback is ~n^2/2, ~3000 n log2 n at n=1e5), and the ratio is flat. Removing the fallback fails both the fallback-reached and bound asserts. Part B uses modest sizes because killer generation is itself O(n^2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The analyzer cannot prove the malloc'd index array is fully initialized before it is read in the mirror sort's comparator, flagging an uninitialized-read BUG (the sole new_reliability_rating failure). calloc the generation buffers; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Follow-up 68999a7 clears the SonarCloud reliability gate ( |
# Conflicts: # tests/CMakeLists.txt
|
gabsow
left a comment
There was a problem hiding this comment.
Verified the latest fixes: duplicate-heavy and adversarial sort regressions pass, non-finite inputs are rejected before mutation, and all CI checks are green.
* Fix O(n^2) compress on duplicate/low-cardinality input (MOD-17228) td_qsort used a single central pivot and recursed both partitions. On a run of equal keys the partition peels one element per level -> O(n^2) comparisons and O(n) recursion depth. Duplicate/low-cardinality input is t-digest's common case (it summarizes streams of repeated measurements), so this is not an adversarial edge: a compress over N buffered equal values is quadratic. Because compress runs inline on the calling thread, a large such compress stalls that thread for the full O(n^2). Measured through the RedisBloom TDIGEST commands on Redis 8.8.1: 430k identical values -> a single TDIGEST.QUANTILE (which triggers compress) blocks the server ~98s. Replace the sort with 3-way (Dutch-national-flag) partitioning plus tail-recursion elimination: equal keys collapse in a single pass (O(n log n) even for all-equal input) and recursing only the smaller side bounds the stack to O(log n). Output is identical to the previous sort. Single compress of N identical values, before -> after: 50k: 1,238 ms -> 1.0 ms 100k: 4,994 ms -> 2.0 ms 200k: 20,069 ms -> 3.2 ms 5M: (hours) -> 80 ms Verified: existing unit suite passes unchanged, plus a new test (all-equal / ascending / descending, 50k each) asserting sorted centroids and correct quantiles; 20,000 randomized parallel-array sort trials; ASan/UBSan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * sort: introsort (guaranteed O(n log n)); complexity + weighted-dup tests Address review on #42: [P1] A deterministic midpoint pivot still permits a crafted DISTINCT-value permutation to force O(n^2). Replace the 3-way quicksort with an introsort: 3-way (Dutch-flag) partitioning for duplicate runs, a median-of-three pivot, and a recursion-depth limit that falls back to heapsort -- guaranteeing O(n log n) worst case and O(log n) stack for any input. Small ranges finish with insertion sort. [P2] Add a complexity regression that OBSERVES the bound instead of relying on correctness/timing: build the library with TD_INSTRUMENT_SORT (a key-comparison counter, zero-cost otherwise), force ONE large compress over an adversarial midpoint-killer permutation, and assert the comparison count stays within an O(n log n) bound (a quadratic sort would need ~n^2/2). New ctest target `td_sort_complexity_test`. Also fixes the cmake unit-test build so ctest runs (minunit needs _POSIX_C_SOURCE, hidden by -std=c99). [P2] The "output is identical" claim was wrong for weighted duplicates: the equal-key order differs, changing how weighted centroids merge. Drop the claim (the sort stays within t-digest's accuracy guarantee) and add `test_weighted_duplicates_accuracy` asserting correct quantiles/CDF/min/max/size. Verified: 22 unit tests / 5.15M assertions + complexity test pass via ctest; ASan+UBSan clean; introsort O(n log n) confirmed on ascending/descending/ all-equal/organ-pipe/center/sawtooth/random over 20k randomized parallel-array trials; clang-format OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * tests: fix SonarCloud reliability issues - td_sort_complexity_test: free the histogram on the early error-return paths (S3584 potential leak of 'h'). - td_test: use an integer loop counter in the CDF sweep instead of a float (S2193 "do not use a counter of type float"). ctest green; LSan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review: fix heapsort int overflow, reject NaN, valid complexity regression Address gabsow's second review on #42: - td_sift_down: `2*i + 1` could overflow signed int for a valid large heap (#41 permits capacities near INT_MAX). Test leaf-ness (`i < n/2`) before forming the child index; for i < n/2, 2*i+1 <= n and cannot overflow. - td_add: reject NaN (EINVAL) before mutating. The centroid sort assumes a total order over means, but NaN compares false to everything and would leave a partition unsorted, breaking td_compress()'s invariant. Matches the reference t-digest; +/-Inf remain accepted (valid ordering). Documented in tdigest.h; added test_add_nonfinite covering NaN-reject / Inf-accept / sorted. - td_sort_complexity_test: the distinct "midpoint killer" was not adversarial for the pre-PR Lomuto sort (partition swaps destroy the midpoint layout; ~1.7M comparisons, under the old bound), so a quadratic revert would pass it. Replace with two measured, multi-size regressions: Part A sorts N identical values -- the actual MOD-17228 vector, where the pre-PR sort is exactly n^2/2 and the 3-way partition is exactly 2n -- and asserts a linear bound; Part B drives the heapsort fallback directly (~1.78 n log2 n) and asserts an O(n log n) bound with a flat normalized ratio across sizes. - Fix the macro redefinition: the file `#define TD_INSTRUMENT_SORT` clashed with CMake's `-DTD_INSTRUMENT_SORT=1`, which -Werror (ENABLE_SANITIZERS) rejects. Guard it with `#ifndef`. - tests/CMakeLists.txt: use CMAKE_CURRENT_LIST_DIR/../src so the include path survives add_subdirectory() consumption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review: clear SonarCloud findings (reliability gate) - Move the sort's comparison counter into an inlined td_key_lt() helper so the boolean conditions no longer carry a comma-operator side effect in the && RHS (S912). Inlines to a plain `a < b` when TD_INSTRUMENT_SORT is off; comparison counts are byte-for-byte identical (verified: all-equal 2n, heapsort ~1.78 n log2 n unchanged). - Split multi-declarations into dedicated statements (S1659). - calloc() the complexity test's scratch buffers so the analyzer no longer traces an uninitialized read through td_heap_sort -> swap_l (S836 BUG, the sole new_reliability_rating failure). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review: reject non-finite means; fallback test through full td_qsort Address gabsow's third review on #42: - td_add: reject all non-finite means, not just NaN. +/-Inf sorts fine but is not closed under the centroid merge: merging two equal infinities computes delta = Inf - Inf = NaN, poisoning a centroid mean and re-breaking the sort invariant (reproduced: 100x +Inf -> 26 NaN centroids). Rejecting !isfinite at ingest keeps every stored mean finite. Header + test_add_nonfinite updated to assert +/-Inf are rejected and the repeated-+Inf reproducer stays empty. - Complexity test Part B rewritten. The old helper drove td_heap_sort() in isolation, so removing the depth-limit fallback did not fail it. It now generates a median-of-three killer with McIlroy's quicksort adversary (run against a faithful mirror of this introsort's comparison sequence), feeds it through the real td_compress()/td_qsort(), and asserts across sizes that the heapsort fallback is actually reached (new td_sort_heap_fallbacks counter), the comparison count stays O(n log n) (measured ~5.7 n log2 n; the same input without the fallback is ~n^2/2, ~3000 n log2 n at n=1e5), and the ratio is flat. Removing the fallback fails both the fallback-reached and bound asserts. Part B uses modest sizes because killer generation is itself O(n^2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: calloc killer-generation buffers to satisfy SonarCloud S836 The analyzer cannot prove the malloc'd index array is fully initialized before it is read in the mirror sort's comparator, flagging an uninitialized-read BUG (the sole new_reliability_rating failure). calloc the generation buffers; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: fcostaoliveira <filipe@redis.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
Superseded by #45, which contains the complete reviewed #42 change set rebased onto the post-#44 master and resolves the shared CMake/test conflicts. The combined tree passed GCC, Clang, ASan+UBSan, SonarCloud, Hound, and the full 3-test CTest suite. It was merged as daf1f4a. Closing this fork PR because GitHub rejected maintainer updates to its head branch with HTTP 403. |



Problem
td_qsort(used by everytd_compress) uses a single central pivot and recurses into both partitions. On a run of equal keys the partition places nothing on the "less" side, splitting off one element per level:Duplicate / low-cardinality input is t-digest's common case — it exists to summarize streams of repeated measurements (latencies, counters), not an adversarial one. So a
td_compressoverNbuffered equal values runs in quadratic time.Because
td_compressruns inline on the calling thread, a large such compress stalls that thread for the full O(n²). Measured through the RedisBloomTDIGESTcommands on Redis 8.8.1 (internal ref MOD-17228): 430k identical values → oneTDIGEST.QUANTILE(which triggers compress) blocks the server ~98 s (a bystanderPINGon a second connection goes from ~2 ms to ~98 s).Fix
Replace the sort with 3-way (Dutch-national-flag) partitioning + tail-recursion elimination:
The sort is guaranteed O(n log n) (introsort with heapsort fallback). Results stay within t-digest's accuracy guarantee, but are NOT centroid-for-centroid identical for weighted duplicates (equal-key order differs) — see the review thread.
Single compress of
Nidentical values, before → after (same machine):Testing
test_duplicate_heavy_compress(all-equal / ascending / descending, 50k each) asserting sorted centroids and correct quantiles for the inputs that were pathological for the old sort.🤖 Generated with Claude Code
Note
Medium Risk
Core compress/sort path and
td_addAPI behavior change (±Inf/NaN now rejected); performance and digest details on duplicate-heavy streams change, though bounded by design and heavily tested.Overview
Fixes MOD-17228 by replacing the central-pivot quicksort used in
td_compresswith introsort: 3-way partitioning for duplicate-heavy centroids, median-of-three pivots, insertion sort on small ranges, recursion only on the smaller partition, and heapsort when the depth limit is hit so worst-case time is O(n log n) with O(log n) stack instead of quadratic work and deep recursion on all-equal input.td_addnow returnsEINVALfor non-finite means (NaN and ±Inf), documented intdigest.h, so sort order and centroid merge math stay well-defined.Tests add
td_sort_complexity_test(optionalTD_INSTRUMENT_SORTcomparison/fallback counters), unit coverage for non-finite adds, large duplicate-heavy compress correctness, and weighted-duplicate quantile accuracy. Equal-key tie order can differ from the old sorter; accuracy remains within t-digest guarantees.Reviewed by Cursor Bugbot for commit 1ced90f. Bugbot is set up for automated code reviews on this repo. Configure here.