Skip to content

[SYSTEMDS-3524] Add multi-threaded unique in LibMatrixSketch#2542

Open
shieru1214 wants to merge 5 commits into
apache:mainfrom
shieru1214:SYSTEMDS-3524
Open

[SYSTEMDS-3524] Add multi-threaded unique in LibMatrixSketch#2542
shieru1214 wants to merge 5 commits into
apache:mainfrom
shieru1214:SYSTEMDS-3524

Conversation

@shieru1214

@shieru1214 shieru1214 commented Jul 11, 2026

Copy link
Copy Markdown

Summary

This PR adds a multi-threaded path for unique in LibMatrixSketch.

The existing single-threaded implementation is kept and is still used as the baseline/fallback. I added a new overload: getUniqueValues(MatrixBlock blkIn, Types.Direction dir, int k), where k controls the requested number of threads.

The implementation follows the existing SystemDS unique direction semantics:

  • RowCol: unique scalar values over the input matrix, returned as one column
  • Row: row-wise unique scalar values, preserving the number of rows
  • Col: column-wise unique scalar values, preserving the number of columns

Implementation

For k > 1 and sufficiently large inputs, the code uses balanced row or column ranges and CommonThreadPool.

The parallel logic is direction-specific:

  • RowCol: each worker builds a local HashSet<Double> over its row range, and the local sets are merged afterwards.
  • Row: rows are partitioned across workers. The code first computes the maximum number of unique values per row, allocates the output, and then fills the row-wise unique values in parallel.
  • Col: columns are partitioned across workers. The code first computes the maximum number of unique values per column, allocates the output, and then fills the column-wise unique values in parallel.

There are two parallel paths:

  • regular parallel deduplication, used when the estimated local memory usage is safe
  • batched parallel deduplication, used when full thread-local temporary data may be too large

The batched path processes a limited number of ranges at a time, clears temporary local data earlier, and then continues with the next batch. If the input is small, k <= 1, or batching is not useful, the code falls back to the original single-threaded path.

Testing

I added a focused JUnit test:

  • src/test/java/org/apache/sysds/runtime/matrix/data/LibMatrixSketchUniqueParallelTest.java

It checks RowCol, Row, and Col, and compares the multi-threaded result with the single-threaded baseline.

I ran the following tests:

  • mvn -Dtest=LibMatrixSketchUniqueParallelTest test
  • mvn -Dtest=UniqueRow,UniqueRowCol test

The focused test completed with 4 tests passing, and the existing unique tests completed with 9 tests passing. I also ran the focused test with -Xmx64m to exercise the memory-aware batched path.

Runtime experiments

I ran local runtime experiments with fixed k=4, comparing against the k=1 baseline.

The experiments were run locally on an Apple Silicon Mac using Java 17. The JVM heap was fixed at 3 GB, and all inputs used dense MatrixBlock storage.

For each case, I used three warm-up iterations followed by five measured iterations. The order of k=1 and k=4 was alternated between iterations to reduce ordering effects, and the median runtime is reported. Input generation is not included in the measurements.

The experiments cover:

  • directions: RowCol, Row, and Col
  • input sizes: small and large
  • unique shares: 1%, 10%, 50%, and 100%
  • thread counts: k=1 and k=4

In the tables, Unique Values has a different meaning for each direction:

  • for RowCol, it is the total number of distinct scalar values in the input
  • for Row, it is the number of distinct scalar values within each row
  • for Col, it is the number of distinct scalar values within each column

The exact integer count used for each case is shown in the tables. Speedup is calculated as k=1 time / k=4 time, so a value above 1 means that the multi-threaded path was faster.

The 3 GB heap limit was chosen to keep the large dense inputs within memory while allowing the memory guard to select the batched path for the large Row and Col cases. With this setting, the smaller cases use the regular parallel path, while the large Row and Col cases use batched parallel processing.

These are local measurements and are intended as an initial comparison rather than a full benchmark.

RowCol

Size Input Unique Share Unique Values k=1 (ms) k=4 (ms) Speedup
small 100000x1 1% 1000 1.598 2.362 0.68
small 100000x1 10% 10000 2.041 4.897 0.42
small 100000x1 50% 50000 2.145 5.307 0.40
small 100000x1 100% 100000 3.092 7.047 0.44
large 2000000x1 1% 20000 23.294 20.875 1.12
large 2000000x1 10% 200000 32.340 112.501 0.29
large 2000000x1 50% 1000000 79.177 115.331 0.69
large 2000000x1 100% 2000000 60.601 110.218 0.55

Row

Size Input Unique Share Unique Values per Row k=1 (ms) k=4 (ms) Speedup
small 10000x64 1% 1 10.561 3.722 2.84
small 10000x64 10% 6 12.057 4.279 2.82
small 10000x64 50% 32 14.747 5.811 2.54
small 10000x64 100% 64 18.601 9.271 2.01
large 200000x64 1% 1 209.592 66.916 3.13
large 200000x64 10% 6 237.736 85.111 2.79
large 200000x64 50% 32 291.955 112.381 2.60
large 200000x64 100% 64 362.697 165.816 2.19

Col

Size Input Unique Share Unique Values per Column k=1 (ms) k=4 (ms) Speedup
small 64x10000 1% 1 14.640 4.453 3.29
small 64x10000 10% 6 15.888 5.031 3.16
small 64x10000 50% 32 20.366 6.899 2.95
small 64x10000 100% 64 22.102 9.930 2.23
large 64x200000 1% 1 296.105 87.217 3.40
large 64x200000 10% 6 323.869 99.900 3.24
large 64x200000 50% 32 418.123 135.996 3.07
large 64x200000 100% 64 447.553 220.738 2.03

The row-wise and column-wise cases show consistent speedups because rows and columns can be processed independently. Their speedup generally decreases as the number of unique values grows, since larger sets and outputs require more work.

RowCol behaves differently because its thread-local sets must still be merged into one global set. The large input with a 1% unique share shows a small improvement, but in the other tested cases the allocation and merge overhead is greater than the saved parallel work.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 9.43396% with 240 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.48%. Comparing base (ae9a59c) to head (bcf2d75).
⚠️ Report is 58 commits behind head on main.

Files with missing lines Patch % Lines
...che/sysds/runtime/matrix/data/LibMatrixSketch.java 9.43% 237 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2542      +/-   ##
============================================
+ Coverage     71.41%   71.48%   +0.06%     
- Complexity    48774    49849    +1075     
============================================
  Files          1571     1602      +31     
  Lines        188912   193298    +4386     
  Branches      37067    37836     +769     
============================================
+ Hits         134917   138184    +3267     
- Misses        43544    44317     +773     
- Partials      10451    10797     +346     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gaturchenko gaturchenko left a comment

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.

Thank you for the contribution @shieru1214. It looks good overall, but there are some issues I would like you to go over.

First, the most critical problem is that the AggregateUnary instruction will always call the single-threaded implementation. With multi-threading in place, it should be only used as a fallback.

Second, the batched multi-threading path has a number of problems related to memory estimation and guards against excessive usage. Below is the summary of suggested fixes:

  1. Int overflow: compute as int end = (int) Math.min((long) pos + config._taskLen, config._len);
  2. The safety condition for Row and Col is numThreads × cellsPerIndex × 64 ≤ budget (one live row/column set per thread). Otherwise, you force batched execution where it can be safely avoided
  3. You have to keep in mind that for batched RowCol the merged HashSet grows toward the distinct count. So you should size taskLen against a fraction of the budget with the remainder reserved for the merged set. If the worst-case simply doesn't fit, fall back to sequential execution.
  4. I'm pretty sure batched tests do not test the respective path in our CI since the JVM heap is never sufficiently low (the worst-case requirement is ~77 MiB). You can, e.g., add an overload like maxLocalBytes, or account for it in any other way you prefer

Also, the formatting job is failing, you need to run the dev/format-changed.sh script. Your fork doesn't have it, so please rebase on the current main so that it's present in your repository as well.

//similar to R's unique, this operation takes a matrix and computes the
//unique values (or rows in case of multiple column inputs)

return getUniqueValues(blkIn, dir, 1);

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.

The degree of parallelism is hard-coded here, so AggregateUnaryCPInstruction will always call the single-threaded implementation.

for( int pos = 0; pos < config._len; ) {
ArrayList<UniqueValueTask> tasks = new ArrayList<>();
for( int i = 0; i < config._numThreads && pos < config._len; i++ ) {
int end = Math.min(pos + config._taskLen, config._len);

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.

This will overflow when pos is close to Integer.MAX_VALUE, i.e., when the number of rows in the matrix is very large

for( int pos = 0; pos < config._len; ) {
ArrayList<UniqueCountTask> tasks = new ArrayList<>();
for( int i = 0; i < config._numThreads && pos < config._len; i++ ) {
int end = Math.min(pos + config._taskLen, config._len);

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.

Same overflow issue

for( int pos = 0; pos < config._len; ) {
ArrayList<UniqueOutputTask> tasks = new ArrayList<>();
for( int i = 0; i < config._numThreads && pos < config._len; i++ ) {
int end = Math.min(pos + config._taskLen, config._len);

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.

Same overflow issue


ExecutorService pool = CommonThreadPool.get(config._numThreads);
try {
HashSet<Double> hashSet = new HashSet<>();

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.

This HashSet is unbounded. When almost all values are unique, the set grows to rlen x clen, so it in principle matches the sequential case.

if( numThreads <= 1 )
return null;

int taskLen = Math.max(1, (int) Math.min(Integer.MAX_VALUE, maxBatchIndexes / numThreads));

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.

Then numThreads x taskLen consumes the entire 1/4 of the heap in the worst case

* @return true if local deduplication is small enough for the parallel path
*/
private static boolean isLocalDedupMemoryBudgetSafe(MatrixBlock blkIn, Types.Direction dir) {
return getMaxLocalDedupIndexes(blkIn, dir) >= getPartitionLength(blkIn, dir);

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.

If I understand correctly, you check here whether you can hold in memory each dedup set for every row at the same time. This is correct for RowCol, but due to hashSet.clear() it is unnecessarily conservative for Row and Col, because the peak memory is then numThreads × clen × 64 bytes which doesn't depend on rlen.

* under the License.
*/

package org.apache.sysds.runtime.matrix.data;

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.

Please, move the tests to respective files in src/test/java/org/apache/sysds/test/functions/unique, feel free to extend for the multi-threaded case if necessary

return 0;

long bytesPerIndex = cellsPerIndex * Double.BYTES * PAR_UNIQUE_LOCAL_BYTES_OVERHEAD;
long maxLocalBytes = Runtime.getRuntime().maxMemory() / PAR_UNIQUE_MAX_LOCAL_BYTES_FRACTION;

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.

Read from InfrastructureAnalyzer.getLocalMaxMemory() instead

@github-project-automation github-project-automation Bot moved this from In Progress to In Review in SystemDS PR Queue Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

2 participants