ERS: sort the hash lists once instead of inserting into a LinkedList by index - #2457
dejmekradim wants to merge 1 commit into
Conversation
…by index SortedHashList and SortedIndexedHashList kept their hashes in a LinkedList and found each insertion point by walking it with get(index). LinkedList.get(i) is O(i), so one add was O(n^2) and building a list of n hashes was O(n^3): 10,000 data objects took 347 s in generateTimeStampRequest, rising by about a factor of eight per doubling. Both lists are on the evidence record generation path - SortedIndexedHashList from ERSArchiveTimeStampGenerator.getPartialHashtrees(), SortedHashList from BinaryTreeRootCalculator.computeRootHash() - and neither can be replaced from outside the library, since getPartialHashtrees() is private. Both classes expose only size(), getFirst() and toList(), so collect into an ArrayList and sort once, lazily, in the accessors. The order is unchanged: the old add inserted each hash after every element comparing <= to it, which is where a stable sort of the insertion sequence puts it, and Collections.sort is stable. getFirst() still throws NoSuchElementException on an empty list. Tests: testSortedHashListOrder reproduces the original insertion algorithm and asserts the list matches it element for element over 1,200 pseudo-random values including duplicates and shared prefixes; testLargeDataObjectSet builds a reduced hash tree over 2,000 data objects, reaching both lists, and asserts the root does not depend on the order the objects were added in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c63d0c0 to
a38c0f5
Compare
|
A correction and an addition on the timings, so the numbers in #2456 and in the description above can be read against each other. They were measured under different machine load, and they disagree by about 3×. The table in #2456 (1,000: 0.36 s / 2,000: 2.5 s / 4,000: 25 s / 10,000: 347 s) comes from an earlier benchmark run; the "before" column in the description here was measured later, directly against the released bcpkix-jdk18on 1.80 jar, on a 4-core box that turned out to have two other JVMs pinned at ~95% CPU throughout. So the same 2,000-object measurement appears as 2.5 s in the issue and 7.0 s here. Re-running 10,000 objects under that same load gave 1,088 s, against the 347 s in the issue. Both series are consistent with O(n³), independently: 2,000 → 10,000 is 5× the input, so cubic predicts 125×.
The absolute values are therefore only as good as the machine they were taken on, and the lower series understates the cost rather than exaggerating it. The ratio between before and after is the part that does not depend on the box. Separately, a stronger equivalence check than the one in the description. The root over 10,000 data objects is byte-identical before and after the change: stock 1.80 in 1,088 s, patched in 0.67 s, same digest, and the same root again for the input in reverse order. |
|
Thanks for the PR! This is now in https://www.bouncycastle.org/betas let us know how it goes. |
|
Tested against Output is unchanged. Our suite regenerates RFC 4998 roots and complete evidence records and compares them byte for byte against vectors frozen from 1.80: 374 comparisons — 8 leaf counts up to 257, every record at each, plus whole Performance, JDK 21, as root build (
Both columns matter because the two lists sit on different paths — Good to ship from our side. |
Fixes #2456.
SortedHashListandSortedIndexedHashListkeep their hashes in aLinkedListand find each insertion point by walking it withget(index).LinkedList.get(i)is O(i), so oneaddis O(n²) and building a list of n hashes is O(n³). Both lists are on the evidence-record generation path —SortedIndexedHashListfromERSArchiveTimeStampGenerator.getPartialHashtrees(),SortedHashListfromBinaryTreeRootCalculator.computeRootHash()— so the cost lands ongenerateTimeStampRequestandgenerateArchiveTimeStamps.Both classes expose only
size(),getFirst()andtoList(), so this collects into anArrayListand sorts once, lazily, in the accessors.The output order does not change. The old
addinserted each hash after every element comparing<=to it, which is exactly where a stable sort of the insertion sequence puts it, andCollections.sortis stable. Checked two ways:testSortedHashListOrder(added) reproduces the original insertion algorithm in the test and asserts the list matches it element by element over 1,200 pseudo-random values including duplicates and arrays sharing a prefix with a longer one.810060f1eb145d7aa90c07d9c8970364c4f7c62cafd45b144f4a60d6a140b5c5both before and after the change.getFirst()still throwsNoSuchElementExceptionon an empty list, asLinkedList.getFirst()did.Measured on JDK 21, timing
generateTimeStampRequestover nERSByteDataobjects:Tests added to
ERSTest:testSortedHashListOrder— the order-preservation check described above.testLargeDataObjectSet— a reduced hash tree over 2,000 data objects, reaching both sorted lists, asserting the root does not depend on the order the objects were added in. This took about seven seconds before the change and a quarter of a second after it, so it also serves as the regression guard.A note on how the tests were run:
./gradlew :pkix:testdoes not build on my machine, for a reason unrelated to this change —:prov:compileJava25Javafails witherror: release version 25 not supported, as I have JDK 21. I therefore compiledERSTestdirectly with javac against the 1.80 jars and ran it both ways. Both added tests pass against stock 1.80 as well as against the patched classes (9.9 s stock, 1.5 s patched);testSortedHashListOrderpassing on the unpatched code is the point of it. I would appreciate a CI run on a box with a JDK 25 toolchain.Disclosure under CONTRIBUTING.md: this contribution was prepared with the assistance of a large-language-model-based tool. I have reviewed and understood the change, have run the tests, and have verified the before/after output equivalence independently by A/B-running the patched classes against the released bcpkix-jdk18on 1.80 jar.