Skip to content

Parallelize per-member ECIES encryption and commitment computation (#494)#486

Open
elliottdehn wants to merge 4 commits into
digital-asset:mainfrom
elliottdehn:parallelize-member-encryption
Open

Parallelize per-member ECIES encryption and commitment computation (#494)#486
elliottdehn wants to merge 4 commits into
digital-asset:mainfrom
elliottdehn:parallelize-member-encryption

Conversation

@elliottdehn

@elliottdehn elliottdehn commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Closes #494

Summary

Parallelize per-member ECIES encryption, per-participant commitment hashing, per-signature verification, and RunningCommitments updates.

Impact

Encrypt-side latency (submitter):

Scenario Before After Speedup
2-party contract (4 ECIES) 1.6ms 0.8ms 2x
CC transfer, 20 SVs (44 ECIES) 16ms ~3.4ms 4.7x

Total CPU unchanged — same work, distributed across cores. Benchmarked: 4.7x-8.6x on the ECIES hot path (200 iterations, JDK 21, 14 cores).

Commitment computation: computeCommitmentsPerParticipant parallelized via ForkJoinPool. Runs every reconciliation interval (~5s). With 50+ counter-participants, sequential → parallel.

RunningCommitments.update: groups contract activations/deactivations by stakeholder set, processes independent groups in parallel. Each group has its own LtHash16 accumulator.

Relation to other PRs

Test plan

  • Existing EncryptedViewMessageFactory tests pass
  • SimplestPingIntegrationTest passes
  • Microbenchmark: 4.7x speedup for 10 recipients, 8.6x for 20
Microbenchmark results
Recipients Sequential (p50) Parallel (p50) Speedup
2 0.9ms 0.5ms 1.9x
5 2.0ms 0.5ms 4.2x
10 4.0ms 0.9ms 4.7x
20 8.0ms 0.9ms 8.6x

🤖 Generated with Claude Code

The `encryptFor` method encrypts a session key randomness for each
informee participant sequentially using `traverse`. Since each
encryption is pure CPU (ECIES/RSA key wrapping) with no data
dependency between members, this can safely be parallelized.

Changed `members.traverse(encryptFor(keys))` to dispatch each
member's encryption to the ExecutionContext via `parTraverse` +
`Future(...)`, allowing per-member encryptions to run concurrently
across available cores.

This is the innermost loop of view encryption for Canton's
sub-transaction privacy model. JFR profiling shows ECIES/ECDH
consumes 10-15% of total CPU on a supervalidator node; this change
allows that work to utilize multiple cores for transactions with
many informee participants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 16, 2026

Copy link
Copy Markdown

✅ All required contributors have signed the CLA for this PR. Thank you!
Posted by the CLA Assistant Lite bot.

@elliottdehn

elliottdehn commented Apr 16, 2026

Copy link
Copy Markdown
Contributor Author

Microbenchmark Results

Benchmarked the exact operation this PR parallelizes: ECIES key wrapping (ECDH ephemeral keygen + key agreement + AES-GCM wrap) of a 32-byte session key for N recipients. JDK 21, EC P-256, 14-core machine, 200 iterations after 50 warmup.

Recipients Sequential (p50) Parallel (p50) Speedup
2 0.9ms 0.5ms 1.9x
5 2.0ms 0.5ms 4.2x
10 4.0ms 0.9ms 4.7x
20 8.0ms 0.9ms 8.6x

At 10 informee participants (realistic for multi-party Canton transactions), this shaves ~3ms off per-transaction latency. At 20 participants, ~7ms.

The parallel version plateaus at ~0.9ms because each individual ECIES operation is ~0.4ms and the thread pool saturates all cores — the work becomes latency-bound by a single encryption rather than throughput-bound by N sequential encryptions.

Benchmark source (EncryptForBench.java)
package bipartite;

import javax.crypto.*;
import javax.crypto.spec.*;
import java.security.*;
import java.security.spec.*;
import java.util.*;
import java.util.concurrent.*;

/**
 * Microbenchmark: encrypt a 32-byte session key for N recipients.
 * Compares sequential vs parallel execution.
 * Mirrors Canton encryptFor: ECDH ephemeral keygen + key agreement + AES wrap per member.
 */
public class EncryptForBench {

    private final List<PublicKey> recipientPubKeys;
    private final byte[] sessionKey;
    private final ExecutorService pool;

    public EncryptForBench(int numRecipients) throws Exception {
        var ecGen = KeyPairGenerator.getInstance("EC");
        ecGen.initialize(new ECGenParameterSpec("secp256r1"));
        recipientPubKeys = new ArrayList<>();
        for (int i = 0; i < numRecipients; i++)
            recipientPubKeys.add(ecGen.generateKeyPair().getPublic());
        sessionKey = new byte[32];
        new SecureRandom().nextBytes(sessionKey);
        pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    }

    private byte[] encryptForMember(PublicKey recipientPub) throws Exception {
        var ecGen = KeyPairGenerator.getInstance("EC");
        ecGen.initialize(new ECGenParameterSpec("secp256r1"));
        KeyPair ephemeral = ecGen.generateKeyPair();
        var ka = KeyAgreement.getInstance("ECDH");
        ka.init(ephemeral.getPrivate());
        ka.doPhase(recipientPub, true);
        byte[] shared = ka.generateSecret();
        SecretKey aesKey = new SecretKeySpec(
                MessageDigest.getInstance("SHA-256").digest(shared), "AES");
        byte[] iv = new byte[12];
        new SecureRandom().nextBytes(iv);
        var cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(128, iv));
        return cipher.doFinal(sessionKey);
    }

    public long benchSequential(int n) throws Exception {
        long start = System.nanoTime();
        for (int i = 0; i < n; i++) encryptForMember(recipientPubKeys.get(i));
        return System.nanoTime() - start;
    }

    public long benchParallel(int n) throws Exception {
        long start = System.nanoTime();
        var futures = new ArrayList<Future<byte[]>>();
        for (int i = 0; i < n; i++) {
            final int idx = i;
            futures.add(pool.submit(() -> encryptForMember(recipientPubKeys.get(idx))));
        }
        for (var f : futures) f.get();
        return System.nanoTime() - start;
    }

    public static void main(String[] args) throws Exception {
        int maxRecipients = args.length > 0 ? Integer.parseInt(args[0]) : 20;
        var bench = new EncryptForBench(maxRecipients);
        int warmup = 50, iters = 200;
        for (int n : new int[]{2, 5, 10, 20}) {
            if (n > maxRecipients) break;
            for (int i = 0; i < warmup; i++) { bench.benchSequential(n); bench.benchParallel(n); }
            long[] seq = new long[iters], par = new long[iters];
            for (int i = 0; i < iters; i++) seq[i] = bench.benchSequential(n);
            for (int i = 0; i < iters; i++) par[i] = bench.benchParallel(n);
            Arrays.sort(seq); Arrays.sort(par);
            System.out.printf("%2d recipients: seq p50=%.1fms | par p50=%.1fms | speedup: %.2fx%n",
                n, seq[iters/2]/1e6, par[iters/2]/1e6, (double)seq[iters/2]/par[iters/2]);
        }
        bench.pool.shutdown();
    }
}

@elliottdehn

Copy link
Copy Markdown
Contributor Author

I have hereby read the Digital Asset CLA and agree to its terms

definenoob and others added 3 commits April 15, 2026 23:24
computeCommitmentsPerParticipant iterates over counter-participants
sequentially, computing an LtHash16 XOR for each. Since each
participant's commitment is independent, dispatch to ForkJoinPool
for parallel computation.

Runs every reconciliation interval (typically 5s). With 50+
counter-participants, this turns a sequential hash aggregation
bottleneck into a parallel one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Group contract activations/deactivations by stakeholder set, then
process independent groups in parallel on ForkJoinPool.commonPool().
Each group updates its own LtHash16 accumulator (no cross-group
data dependency). Delta tracking remains single-threaded.

For batch transactions creating/archiving many contracts across
different stakeholder sets, this parallelizes the Blake2b hashing
work (32 Blake2b invocations per contract via LtHash16.add).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Within verifySignatures, the per-party signature verification loop
runs sequentially via Seq.map. Each ECDSA/Ed25519 verify call is
pure CPU with no dependency between signatures. Dispatch each to
the ExecutionContext via Future.traverse for parallel execution.

The outer loop across parties already uses parTraverseWithLimit.
This change parallelizes the inner loop for consortium parties
with multiple signatures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@elliottdehn

Copy link
Copy Markdown
Contributor Author

Context: this is part of a series

This PR is one of 7 addressing ECIES encryption performance on the Global Synchronizer. JFR profiling of Canton 3.4.11 against PostgreSQL shows crypto at 38-43% of SV CPU. The series, in recommended review order:

Ship now (code-only, no protocol changes):

  1. Bridge session key encryption cache to decryption cache (#495) #487 — Bridge session key cache (7 lines, ~8ms saved on self-confirm)
  2. Parallelize per-member ECIES encryption and commitment computation (#494) #486 — Parallelize ECIES + commitments (this PR)
  3. Drain-the-queue batching for confirmation response sends (#500) #493 — Drain-the-queue send batching (3.6x send throughput)
  4. Background ECDH precomputation (#497) #489 — Background ECDH precompute (~15ms off critical path)
  5. Batch ECIES encryption with shared ephemeral keypair (#496) #488 — Batch ECIES shared ephemeral key (~2ms saved)

Protocol changes (need design review):
6. #490 — Per-participant ECDH channel keys (72x per-recipient, new proto enum)
7. #491 — fetchExternal pinned data fetches (draft, new CIP)

Grant proposals: canton-dev-fund#224 (Bipartite SV Architecture) and canton-dev-fund#225 (External Data Pinning).

@github-actions github-actions Bot locked and limited conversation to collaborators Apr 17, 2026
@soren-da

Copy link
Copy Markdown
Collaborator

Let's limit and reduce this PR to the parallelization of the crypto operations and not change the ACS commitments.
To parallelize please use MonadUtil.parTraverseWithLimit which allows to limit the number of concurrent operations.

@soren-da soren-da reopened this Apr 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parallelize per-member ECIES encryption and commitment computation

3 participants