Parallelize per-member ECIES encryption and commitment computation (#494)#486
Parallelize per-member ECIES encryption and commitment computation (#494)#486elliottdehn wants to merge 4 commits into
Conversation
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>
|
✅ All required contributors have signed the CLA for this PR. Thank you! |
Microbenchmark ResultsBenchmarked 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.
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();
}
} |
|
I have hereby read the Digital Asset CLA and agree to its terms |
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>
Context: this is part of a seriesThis 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):
Protocol changes (need design review): Grant proposals: canton-dev-fund#224 (Bipartite SV Architecture) and canton-dev-fund#225 (External Data Pinning). |
|
Let's limit and reduce this PR to the parallelization of the crypto operations and not change the ACS commitments. |
Closes #494
Summary
Parallelize per-member ECIES encryption, per-participant commitment hashing, per-signature verification, and RunningCommitments updates.
Impact
Encrypt-side latency (submitter):
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:
computeCommitmentsPerParticipantparallelized 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
EncryptedViewMessageFactorytests passSimplestPingIntegrationTestpassesMicrobenchmark results
🤖 Generated with Claude Code