Skip to content

Multi-GPU tuning: measure every device, report per device, and stop hiding wrong results#320

Merged
bernardladenthin merged 21 commits into
mainfrom
claude/statistics-keys-per-producer
Jul 26, 2026
Merged

Multi-GPU tuning: measure every device, report per device, and stop hiding wrong results#320
bernardladenthin merged 21 commits into
mainfrom
claude/statistics-keys-per-producer

Conversation

@bernardladenthin

Copy link
Copy Markdown
Owner

Summary

  • Multi-GPU tuning that measures every device. TuneConfiguration used to take producerOpenCL.get(0) silently: a dual-GPU config produced a full-looking report for one card plus a paste-ready configuration whose second entry still held the operator's guesses, rendered identically to the measured one. All configured devices are now swept in turn, each keeping its own winner, and an empty producerOpenCL — previously an error — means "sweep every GPU you find", which is the state most first runs are in.
  • Results that no longer look right when they are wrong. Several changes share one theme: a crashed producer was reported as a measurement, a 0.00 arm was indistinguishable from a driver rejection, batch counts read as an even split where the real work split was 20:1, and every rate above 1 G/s collapsed to 1 G/s. Each of those made a bad number look like a good one.
  • Contributed measurements and the tooling to keep collecting them. Two multi-GPU machines added to the registry, a step-by-step tuning guide, and the fixes that made their data possible to gather at all.

Driven by field reports in #250 and discussion #281, and counter-tested throughout on a second machine with two GPUs behind a duplicate ICD. That machine found three defects unreachable from single-GPU hardware — all fixed here.

Behaviour changes a reviewer should know about

Change Why
Batches per producerKeys per producer, with each device's share Batches are not comparable between producers: one yields 2^batchSizeInBits candidates
Empty producerOpenCL now means "sweep all detected GPUs" (was an error) The state a first-time operator is actually in
disableAddressLookup: true no longer opens LMDB It failed when no database existed — the situation the flag exists to permit
The fat jar runs with a bare java -jar Add-Opens moved into the assembly manifest; launchers still set heap and Logback
Rates keep four significant digits 1.000 and 1.499 G/s both printed as 1 G/s
Three architecture rules extended: Cli→Util, Command→Constants, Command→Opencl Each downward, no cycle, each commented at the rule

Test plan

  • Affected unit / integration tests pass locally — 133 across TuneConfigurationTest, ConsumerJavaTest, MainTest, AbstractProducerTest, StatisticsTest, RuntimeStatisticsTest, MultilineLoggerTest, OpenCLDeviceTopologyTest, JvmModuleFlagConsistencyTest, ExampleRunScriptJarVersionTest, ConfigFixturesParseTest and the architecture rules, plus 14 Python tests
  • CI is green on this branch
  • Docs / CHANGELOG updated where applicable

Written test-first throughout; every fix here had a failing test before it had an implementation. A full mvn test was not run — per the repo's test-scoping policy, only the affected surface was exercised. Verification on real hardware (dual-GPU, dual-ICD, LMDB, bare java -jar) happened on a second machine and is recorded in the commit messages.

Known limitations, deliberately left

  • The cl_khr_device_uuid and cl_khr_pci_bus_info fingerprint paths have never executed on real hardware. The only dual-ICD machine available reports neither extension, so only the AMD fallback ran there. They are unit-tested against their byte layouts, and de-duplication additionally requires the device name to agree — so the dangerous failure (a driver reporting one identity for several devices, silently halving a machine) cannot occur even if a path is wrong.
  • The interference figure between an integrated and a discrete GPU is a range, not a number. Measured ~3× on a laptop pair and ~1.5× on a desktop pair; the guide shows both and says to measure your own.
  • The extension follows the winner's own row and does not re-explore the other axis. A full second cross product would cost as much as the sweep it is trying to save.
  • CLAUDE.md referenced ./mvnw seven times and no wrapper exists — corrected to mvn here rather than adding a wrapper, which is a separate decision.

Related issues / PRs

Refs #250, refs #281

Checklist

  • I have read CONTRIBUTING.md and CODE_OF_CONDUCT.md
  • My commits follow Conventional Commits
  • No security-sensitive changes — the CRLF log-injection guard is touched but not weakened: multi-line reports are split into one record per line, so every emitted line still carries a prefix the appender wrote, and the split consumes every line-break form. Reasoned through in util/MultilineLogger and covered by tests.

bernardladenthin and others added 21 commits July 25, 2026 17:18
…h counts

The statistics line's "Batches per producer" group counted dispatched
batches, which are not comparable between producers: one batch yields
2^batchSizeInBits candidates. On a dual-GPU run with batchSizeInBits 24
and 21, batch counts of 1156 vs 595 read as a ~2:1 split while the actual
work split was 20:1 (94.0% vs 6.0% of generated candidates) — exactly the
question the group exists to answer when more than one device is
configured. Reported from a user's dual-GPU logs in discussion #281.

Replace it with "Keys per producer", printing each producer's generated
candidate count and its share of the total:

  [Keys per producer: gpu0 (Incremental, GPU)=19 G (94.0%),
                      gpu1 (Random, GPU)=1 G (6.0%)]

RuntimeStatistics gains a per-label generated-candidate map alongside the
existing batch map. addGeneratedKeys(long) becomes
addGeneratedKeys(String, long): the label is required rather than optional
so a caller cannot contribute to the lifetime total without naming its
producer, which would leave the rendered shares summing to less than the
whole. Batch counts are still tracked — TuneConfiguration consumes them —
only the rendered field changed.

Statistics gains describeProducerShares() and formatCount(), the latter
using the same k/M/G thresholds as formatRate() so both read consistently
within one line. A zero total omits the share rather than printing NaN%,
for the window where a producer is registered but its first batch is
still in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g guide

Three user-reported problems from issue #250 and discussion #281, plus the
documentation that should have existed alongside TuneConfiguration.

1. Rate and count formatting collapsed precision at every unit boundary
   (reported in #250). formatRate/formatCount rounded the scaled value to a
   whole number, so 1.000 and 1.499 G/s both printed as "1 G/s" and tuned runs
   became impossible to compare once they crossed 1 G/s. The reporter noticed it
   at G/s, but the same collapse happened at k/s and M/s. Both now keep four
   significant digits via withSignificantDigits: 1.400 G/s, 130.0 M/s,
   2.013 k/s, 412/s. A value within rounding distance of the next unit renders
   as 1000.0 M/s rather than being promoted; that costs a second format pass to
   detect the carry, for a band a live rate crosses in one sample, so it is
   pinned by a test instead.

2. Multi-line reports arrived as one line separated by " | " (reported in
   #250). The cause is not TuneConfiguration but the CRLF log-injection guard
   %replace(%msg){'[\r\n]+', ' | '}, present in both logback.xml and
   examples/logbackConfiguration.xml, so it affected every user. Users were
   pasting the report into an editor and replacing the separators by hand.

   util.MultilineLogger emits trusted formatted blocks as one record per line,
   wired into TuneConfiguration (report), OpenCLContext (device dump) and Main
   (transformed-configuration echo). This does not weaken the guard: the
   property it defends is that every physical line carries a prefix the appender
   wrote, and splitting preserves that because the appender stamps each record
   itself. The split consumes every line-break form so no line can carry another
   past it, and each line is passed as an argument rather than as the format
   string. Called out for Main in particular because its YAML rendering, unlike
   the JSON one, can carry a real line break out of a user-supplied value such
   as vanityPattern.

3. TuneConfiguration printed a bare 0.00 for arms that ran without error but
   completed no batch inside the measurement window, which is indistinguishable
   from a driver rejection at a glance. On an integrated GPU, batchSizeInBits=22
   with keysPerWorkItem=1 needs ~40 s against a 20 s window. ArmResult gains
   tooSlowToMeasure() and both the report table and the per-arm log line now say
   NOT MEASURED with the reason and the remedy.

Also adds docs/tuning-your-gpu.md, a step-by-step guide linked from the README:
device indices, config fields, capturing the log, reading the report, tuning a
second GPU, contributing measurements, troubleshooting. Two things in it are new
documentation rather than restatement — the integrated-GPU contention effect (an
Arc iGPU measured 16.3 M keys/s alone and 5.5 M/s alongside a discrete card,
because it shares system RAM with the host) and the truncated-sweep trap (a
winner sitting at the largest swept keysPerWorkItem means the real optimum is
higher).

Architecture rule: "Cli" is added to the Util layer's accessor list, because
Main now logs through util.MultilineLogger. Cli is the topmost layer, so the
dependency is downward and introduces no cycle; its earlier absence was
incidental rather than a deliberate restriction.

README fix: the TuneConfiguration section claimed "25 arms x 25 s ~ 10 minutes".
The default candidate lists are 7 x 5 = 35 arms, about 15 minutes, as confirmed
by "Arm 21/35" in a user-supplied log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems, found while registering a contributor's ThinkPad with an RTX 500
Ada and an integrated Arc Pro for discussion #281.

detect_gpu() returned as soon as nvidia-smi named anything, which made the
platform-wide enumeration below it unreachable on exactly the machines that need
it most: a laptop with an NVIDIA card and an integrated GPU recorded only the
NVIDIA. The contributor's Arc — a device this project runs on, and one whose
measurements we specifically wanted — was silently dropped. All sources are now
consulted and merged, with duplicates removed, including the case where one
source's name is contained in the other's.

The field itself was a single string, with multiple GPUs represented by joining
them with ", " (one existing entry did this). Multi-GPU machines are the normal
case here rather than the exception, so gpu is now a real JSON list: readers no
longer have to split on a separator that a device name could itself contain.
Both existing registry entries are migrated. --set takes a comma-separated value
for list fields, so a correction stays one flag:

    register_machine.py --set gpu="RTX 500 Ada,Arc Pro Graphics"

The "keep a manually supplied value" path now treats an empty list as "nothing
detected", so a hand-curated gpu list survives a re-run on a machine where
detection comes up empty, matching how the storage string already behaved.

plot.py reads only cpu.l3_mb from the registry, so nothing downstream is
affected. Verified locally: detection went from one GPU to two, --set with two
values round-trips through a write and a re-run, and machines.json stays valid.

Documented in docs/measurements/README.md and docs/tuning-your-gpu.md, both of
which now also warn that detection reports what the operating system enumerates,
which need not match the OpenCL device list — OpenCLInfo remains authoritative
for which devices this tool can actually drive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds coreultra7155h-95g-win11 (Lenovo ThinkPad P14s Gen 5: Core Ultra 7 155H,
24 MB L3, 95.5 GB RAM, JDK 26.0.1, Crucial P3 Plus 4TB NVMe) carrying both an
NVIDIA RTX 500 Ada and an integrated Intel Arc Pro, plus tuner_coreultra7155h.csv
with all 84 measured arms across the two devices. Contributed through discussion
281.

Every machine registered so far has been single-GPU high-end desktop hardware,
so this is the first data point with a laptop discrete card, the first with an
integrated GPU, and the first where the same host drives two devices that
interfere with each other.

Measured peaks: 110,725,506 candidates/s on the RTX at batchSizeInBits=24,
keysPerWorkItem=2048, and 36,481,092 candidates/s on the Arc at 23, 2048. Both
sweeps ran 20 s arms with a 5 s warmup against a 140,964,881-entry target, with
verification cost measured on the contributor's own storage (10.69 and 10.11 us).
The rows were parsed out of the submitted logs rather than transcribed.

The gpu list in the registry entry was completed by hand: the contributor's run
predates the detect_gpu fix in the previous commit, so the script reported only
the NVIDIA and omitted the Arc entirely. The entry's notes record that, along
with the contention effect measured on this machine — the integrated GPU loses
about 3x when the discrete card runs alongside it, because it shares system RAM
with the host.

Also restores REUSE compliance, which had been failing on main and which this
commit would otherwise have pushed one file further from green. Measurement data
and generated plots are covered by globs rather than by name, so future machines
and benchmark runs do not each need an edit — that is how these drifted out of
coverage. Four example configs that were never added to the existing list are
included too. Verified: 486/486 files compliant, was 462/486.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ement

A producer that died part-way through its measurement window was reported with
whatever throughput it managed before dying, sitting in the report next to
healthy arms with nothing to distinguish it.

Found in a contributor's Intel Arc sweep in discussion 281. The
batchSizeInBits=19, keysPerWorkItem=8192 arm's producer hit CL_OUT_OF_RESOURCES
seconds into its window and stopped; the report presented "1,022,288
candidates/s" for it. The only tell was an addresses-checked rate of 0.00, which
is easy to read past. Worse, that arm immediately preceded a cascade in which
every larger grid failed with CL_OUT_OF_HOST_MEMORY — including combinations
that had measured fine an hour earlier — so the one row that would have
identified the cause was the one dressed up as normal.

The exception never had a way out of the producer: run() catches it on the
executor thread and logs it, and ProducerState.NOT_RUNNING cannot distinguish
"asked to stop" from "died". Whoever started the producer therefore saw only a
producer that had stopped producing.

Producers now record what terminated their run loop, exposed through
ProducerStateProvider#getTerminalFailure(), and TuneConfiguration consults it
after the window instead of trusting the rate. Such an arm is recorded as FAILED
with the cause. The partial rate is discarded rather than reported beside the
failure: how much of the window the producer survived is unknown, so keeping it
would invite exactly the comparison it cannot support.

Written test-first. Both tests were confirmed red — getTerminalFailure() and
armResultFor() did not exist — before either was implemented:

  AbstractProducerTest
    getTerminalFailure_exceptionInProduceKeys_returnsTheExceptionThatStoppedTheLoop
    getTerminalFailure_producerRanWithoutError_returnsNull
  TuneConfigurationTest
    armResultFor_producerDiedDuringTheWindow_isRecordedAsFailedNotAsAMeasurement
    armResultFor_producerSurvivedTheWindow_keepsTheMeasuredRate

The second test of each pair guards the obvious over-correction: a clean
shutdown must not be reported as a failure, and a surviving producer must keep
its measured rate.

Not fixed here: why the device stays wedged after the first CL_OUT_OF_RESOURCES,
such that grids which worked minutes earlier then fail. That is device or driver
state we cannot diagnose from a log, and possibly not ours at all. What this
change does is stop the sweep from hiding the arm that caused it.

Verified: 81 tests across AbstractProducerTest, TuneConfigurationTest,
ProducerStateTest, FinderTest and the architecture rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit fixed the wrong failure mode. It tracked producers that
die, and the Intel Arc case in discussion 281 is not one: ProducerOpenCL
catches a CLException per secret, logs it and continues, so the producer never
died and getTerminalFailure() stayed null. The arm that started the whole
investigation was still reported as a measurement.

Re-reading the submitted log made this unambiguous. All 40 CL_OUT_OF_RESOURCES
fall between 16:45:02,518 and 16:45:22,496, entirely inside arm 8, which the
sweep closed at 16:45:22,999 and recorded as 1,022,288 candidates/s. The
producer was alive and failing for the whole window. The 60 CL_OUT_OF_HOST_MEMORY
of the later arms come from initProducer() on the main thread, propagate
normally, and were already reported correctly before either commit.

So a producer has two failure paths and neither reached the thread that started
it:

  fatal      - escapes produceKeys(); run() logs it and leaves the loop, and
               ProducerState.NOT_RUNNING cannot say whether it was asked to stop
               or died
  swallowed  - caught per secret, logged, loop continues; the producer stays
               alive and looks entirely healthy while producing nothing

getTerminalFailure() is therefore replaced by getLastFailure(), recorded at both
funnels: run()'s catch, and logErrorInProduceKeys(...), which is the single point
every swallowed failure already passed through. Only the most recent throwable is
kept — a 20-second failure storm reports the same cause repeatedly, and what
callers act on is whether anything went wrong at all.

Written test-first again. The new test was confirmed red before the fix:

  AbstractProducerTest
    getLastFailure_secretFailureSwallowed_isRecordedAlthoughTheProducerKeepsRunning

It asserts the producer is still running after the swallowed failure, which is
exactly what the previous commit's terminal-failure approach could not detect.
The three existing tests move to the unified accessor.

Also consolidates the [Unreleased] changelog section, which had accumulated
duplicate Added/Changed/Fixed headings across this branch's commits, and corrects
the entry that described the fix in terms of a producer dying.

Verified: 84 tests across AbstractProducerTest, TuneConfigurationTest,
ProducerStateTest, ProducerJavaTest, ProducerOpenCLTest and FinderTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of what this branch learned versus what it can prove found three gaps.
Everything asserted in Java about the reporting model was covered; what a user
actually reads was not, and nothing in the repository exercised the Python at all
-- which is where one of the bugs actually was.

1. Report rendering. describeArm/buildReport had no assertions whatsoever, and
   the report was changed twice on this branch. Now pinned per outcome: a
   measured arm shows its rate and no marker, a rejected arm says FAILED and
   carries the driver's cause, and a too-slow arm says NOT MEASURED, states it is
   not a device error, and names the lever to pull. Two situations printing alike
   is what made the Arc investigation slow.

2. MultilineLogger integration. MultilineLoggerTest proved the helper; nothing
   proved the call sites use it. Reverting TuneConfiguration or Main to a single
   LOGGER.info would have left every test green while users got the unreadable
   ' | ' form back. Both are now asserted end to end, including that no emitted
   record contains a line break -- the property the CRLF guard exists to enforce.

3. Python had no tests and no way to run any. Added stdlib-unittest coverage for
   register_machine.py: the nvidia-smi short-circuit, the list schema, --set
   comma splitting, the empty-list-preserves-existing path, machine-id
   determinism, and a check that every shipped registry entry matches the schema.
   Stdlib rather than pytest on purpose -- the script is meant to be downloaded
   as a single file and run by a contributor, so its tests should not demand an
   install either.

   Run with: python -m unittest discover -s docs/measurements -p 'test_*.py'

Writing them immediately found two real defects, both fixed here:

  - The two-directional dedup in detect_gpu was one-directional. It skipped a new
    name contained in a kept one but not the reverse, so "NVIDIA RTX 500 Ada"
    from nvidia-smi followed by "NVIDIA RTX 500 Ada Generation Laptop GPU" from
    the OS recorded the same card twice. The more specific name now wins. This
    was in the fix committed earlier on this branch and hand-verification missed
    it, because the machine it was verified on reports identical names.
  - main() crashed with ValueError on its own success message when the registry
    sits outside the repository, because relative_to() raises rather than
    degrading. It now falls back to the absolute path.

14 Python tests (0.1 s, hardware probes stubbed for the registry cases) and 84
Java tests across TuneConfigurationTest, MainTest, MultilineLoggerTest,
AbstractProducerTest, StatisticsTest and RuntimeStatisticsTest. REUSE still
486/486.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…take

Running the new register_machine tests produces docs/measurements/__pycache__,
and the previous commit swept it in with git add -A. Two generated .pyc files
therefore landed in the repository, and REUSE dropped to 487/489 because binary
bytecode carries no licence header and never should.

.gitignore had no Python rules at all, since until now nothing in the repository
was executed as Python by a test. Adding __pycache__/ and *.py[cod] stops this
recurring for the next person who runs them.

Verified: REUSE back to 487/487 compliant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A winner sitting on the largest value swept is a lower bound, not a peak: the
sweep stopped looking, not the hardware. The previous answer to that was a
sentence in the guide telling the operator to widen the list and start over,
which only reaches someone who reads it beforehand — and a 20-minute measurement
is run once. Both of a contributor's first sweeps in discussion 281 won at the
shipped list's last keysPerWorkItem of 256; widening by hand afterwards gained
17 % on their RTX 500 and 104 % on their Arc.

TuneConfiguration now continues past the configured candidates on its own,
doubling keysPerWorkItem or adding one bit to batchSizeInBits, stopping at the
first extra arm that fails to beat the winner. The usual cost of being wrong is
therefore one arm, not a second sweep. maxExtensionArms (default 4) bounds the
case where every step keeps improving, and extendSweepWhenWinnerIsAtTheEdge
turns it off.

Edge of the list versus edge of the possible
--------------------------------------------
Conflating the two would make this useless, so they are separated explicitly:

  keysPerWorkItem  bounded by 2^batchSizeInBits, because OpenClTask computes work
                   items as grid / keysPerWorkItem and rejects anything larger
  batchSizeInBits  bounded by BIT_COUNT_FOR_MAX_CHUNKS_ARRAY (24), which the
                   shipped candidate list already reaches

A winner at 24 is therefore at the framework maximum, not at a truncation.
Extending there would only produce a rejected arm, and flagging it would fire on
most default runs until nobody read it any more. The report states the
distinction in words via winnerBoundaryNote, so a reader seeing the winner in the
table's last row knows which of the two happened.

A CPU producer is excluded outright: keysPerWorkItem is inert there, so extending
would spend minutes re-measuring one arm.

Written test-first; all nine assertions were confirmed red before the
implementation existed. They pin both boundaries, the CPU case, a failed winner,
and the report wording. One further test proves the loop actually runs on real
hardware — a one-candidate sweep necessarily wins at the edge, so leaving
extension on must produce more arms than candidates, which the decision-function
tests cannot show because they never start a producer.

run_openClProducer_producesOneArmPerCandidatePair now switches extension off. It
asserts the grid enumeration alone, and its expected count is a cross product;
the failure it produced here was the new behaviour working, not a regression.

Architecture rule: "Command" is added to the Constants layer's accessor list.
Reading BIT_COUNT_FOR_MAX_CHUNKS_ARRAY is the point — hard-coding 24 in the
command would be a second source of truth for a ceiling the kernel layout owns.
Constants is the leaf, so the dependency is downward and adds no cycle.

Also adds .github/workflows/measurement-tools.yml, a separate pipeline for the
Python tooling tests: no JDK, no Maven, sub-second, path-filtered to
docs/measurements. Kept out of publish.yml because it shares none of that setup,
and out of every unrelated push because a check that is usually irrelevant is a
check people learn to ignore. Python 3.9 and 3.13 are both exercised; 3.9
compatibility was verified with ast.parse(feature_version=(3, 9)) rather than
assumed.

The guide's "widen and re-run" advice is replaced by a description of what the
tool now does by itself.

Verified: 114 Java tests, 14 Python tests, REUSE 487/487, spotless clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tuner took producerOpenCL.get(0) and ignored the rest without a word. Someone
with two GPUs naturally lists both, waits twenty minutes, and receives a
full-looking report for one device plus a paste-ready configuration whose second
entry still holds their guesses -- rendered identically to the measured one. On
the contributor's laptop in discussion 281 that guess was 21/256 for a device
whose measured optimum was 23/2048: 16.0 M keys/s where 36.5 M were available,
2.3x off, with nothing in the output able to reveal it. That is the same class of
quietly-wrong result this branch has been removing throughout.

All configured producers are now swept in turn, each keeping its own winner,
which is applied to its own entry. Picking one winner across devices would have
handed the integrated GPU the discrete card's 24/2048.

An empty producerOpenCL list used to be an error. It is instead the state most
first runs are in -- the operator does not yet know what to write -- so it now
means "sweep every GPU you find". Being told to configure a producer first is no
use to someone who ran the tuner precisely to learn what to configure. Listing
entries explicitly remains the override, and when the machine has more GPUs than
were listed the log says so rather than leaving it to be noticed.

Details that make it usable rather than merely correct:

  - CPU OpenCL runtimes are excluded. pocl and Intel's CPU ICD enumerate as
    devices but duplicate producerJava while costing a full sweep of wall-clock,
    and the project's own OpenCL CI job runs on pocl.
  - Devices are measured one at a time. Two at once contend for host memory
    bandwidth: this session measured an integrated GPU losing 3x its solo
    throughput that way, so sweeping in parallel would measure the interference.
  - The filter payload is built once and reused across devices. Only the sweep
    repeats, so N devices cost N sweeps plus one build, and the run time is
    stated up front.
  - Templates are copied by JSON round-trip, not field assignment. CProducerOpenCL
    has fifteen-odd settings and gains more; a hand-written copy silently drops
    whichever was added last, and the result would be a device measured under
    settings nobody chose.
  - The emitted configuration gives each producer its own key producer. Sharing
    one is accepted at runtime -- KeyProducerIdIsNotUniqueException guards
    duplicate definitions, not shared references -- but every shipped
    multi-producer example gives each its own, and a generated config should not
    ship a convention violation the operator never made.

The example config now ships with an empty producerOpenCL, so the copy-paste path
gets the all-devices behaviour rather than a hard-coded platformIndex 0.
ConfigFixturesParseTest asserts that emptiness deliberately.

Architecture rule: "Command" is added to the Opencl layer's accessor list. The
tuner has to enumerate devices to sweep them, which is the discovery Cli already
performs for OpenCLInfo; the alternative is passing a device list into a command
built from configuration alone.

Also refreshes the database tier figures: targetDatabaseEntries defaults to
141045995 (Light, 2026-07-20 publication) instead of the stale 132288304, and the
Full tier reference becomes 1472947953. Historical measurement tables keep the
counts they were actually measured at -- changing those would falsify records
rather than update them.

Written test-first. Verified: 120 Java tests, 14 Python tests, REUSE 487/487,
spotless clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…een via two ICDs

On a machine where two OpenCL platforms each expose the same physical GPUs (a
duplicate ICD registration, common on Windows AMD systems), the auto-sweep
counted every platform*device entry as a distinct GPU: four entries for two
cards. It then measured each card twice and emitted a producerOpenCL list that
drove each card with two contending producers, and the "N GPUs are present"
hint over-reported the count.

- Add OpenCLDeviceTopology to resolve a PCIe bus/device/function fingerprint via
  AMD's cl_amd_device_attribute_query (CL_DEVICE_TOPOLOGY_AMD). Any other vendor,
  a missing extension, or a driver error yields null, which is treated as
  "unknown, keep the device" -- so an unsupported device can only fall back to
  today's behaviour, never regress it, and a rig of identical cards (same name,
  distinct PCIe slots) is never wrongly merged.
- dedupePhysicalDevices collapses entries that share a non-null fingerprint,
  keeping the first. gpuDevicesOf now returns physically-distinct GPUs, so both
  the sweep and the undetected-devices hint see the real count.
- renderPerDeviceWinners adds a "Winner per device (MEASURED)" section to the
  report. The per-device optimum was previously only visible in the emitted JSON;
  the human report showed a single global winner over a device-unlabelled arms
  table, hiding that a slower card wanted different settings.

Validated on a Radeon RX 7900 XTX + Radeon iGPU box with a duplicate AMD ICD:
four detected entries collapse to two by PCIe fingerprint, and the report lists
each card's own winner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g913gVNXb5wJfnZMM6E96
…no GPU filter is needed

initLMDB opened the LMDB env unconditionally, so disableAddressLookup=true still
required a valid lmdbDirectory on disk: with none, Find failed at startup with an
lmdbjava ESRCH error (or, without the JVM add-opens flags, an
InaccessibleObjectException). disableAddressLookup only ever short-circuited the
per-key containsAddress call, never the env open, so the documented "run without a
database" fallback did not actually work.

When address lookup is disabled and no GPU pre-filter has to be built from the
database, skip the env open entirely and answer every lookup as absent via a
self-contained AbsentAddressPresence. The GPU-filter path is unaffected: it still
needs LMDB open and is explicitly excluded from the skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g913gVNXb5wJfnZMM6E96
… runs unaided

The runnable fat jar carried only Main-Class, so `java -jar
bitcoinaddressfinder-*-jar-with-dependencies.jar <config>` crashed the moment it
opened LMDB: lmdbjava reflects into java.nio.Buffer.address / sun.nio.ch /
jdk.internal.ref, which Java 21 does not open by default. The examples/run_*
launchers pass the flags on the command line, but a bare `java -jar` did not, and
nothing in the output pointed at the cause.

Bake the java.base add-opens set (java.lang, java.io, java.nio, jdk.internal.ref,
jdk.internal.misc, sun.nio.ch) into the assembly launcher manifest so the fat jar
is self-sufficient. Kept in sync with .mvn/jvm.config; the wider
jdk.compiler/jdk.management opens the run scripts also carry are for Error Prone
and JMH, which the fat jar never runs.

Validated: `java -jar` (no flags) now runs Find against a real 6.2 GB LMDB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g913gVNXb5wJfnZMM6E96
…on, guard it

Every examples/run_*.bat|sh launcher and docs/tuning-your-gpu.md still named
bitcoinaddressfinder-1.7.0-jar-with-dependencies.jar, but a build from source
produces 1.8.0-SNAPSHOT, so the launchers failed with "jar not found" after the
version bump. Update all 22 launchers and the guide to the current version.

Add ExampleRunScriptJarVersionTest as a hard gate: it reads the project version
from pom.xml and asserts every bitcoinaddressfinder-<v>-jar-with-dependencies.jar
reference in the launchers and docs matches, so the next version bump cannot leave
them stale. The jar-name pattern never matches a plain version mention, so scanning
docs is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g913gVNXb5wJfnZMM6E96
…, and correct the docs

Follow-up to the four commits pulled from the dual-GPU machine. The work was
sound; three things needed finishing.

1. The de-duplication was AMD-only, and it did not have to be.

   It keyed on cl_amd_device_attribute_query, with a null fallback for every
   other vendor -- correct but narrow: a non-AMD machine got no fingerprint at
   all, so a card seen through two ICDs would still have been swept twice. A
   contributor's device dump settles it: both an NVIDIA RTX 500 Ada and an Intel
   Arc report cl_khr_device_uuid and cl_khr_pci_bus_info, which are Khronos
   standards designed for exactly this.

   The resolution order is now UUID, then PCI bus info, then the AMD vendor
   extension for drivers predating both. The method is renamed accordingly:
   pciFingerprintOf -> physicalDeviceFingerprintOf, since a UUID is not a PCIe
   address and the old name would have lied about two of the three sources.

   The parsers are separated from the CL call and unit-tested, which the AMD path
   never was -- it could only be exercised on the one machine that had the
   hardware. That matters more than usual here: a wrong offset yields a
   plausible-looking fingerprint, and a plausible-looking wrong fingerprint either
   merges two distinct GPUs (silently halving a rig) or fails to merge one card
   seen twice. Neither surfaces as an error at runtime. 14 tests now pin the byte
   layouts, the all-zero-UUID rejection, unsigned bus numbers, host byte order,
   and whole-token extension matching.

   Two defects found while writing them: the AMD type field was read as its first
   byte rather than as the cl_uint it is (correct only on little-endian hosts),
   and extension detection used a substring match, which would claim
   cl_khr_device_uuid support on a device advertising only
   cl_khr_device_uuid_extended.

2. The manifest change made four documentation passages wrong.

   Baking Add-Opens into the fat jar means a bare `java -jar` now works, but the
   README and the tuning guide still said it throws InaccessibleObjectException,
   and the guide's Troubleshooting entry diagnosed a problem that can no longer
   occur -- the worst kind of stale documentation, since it sends a reader after a
   cause that is not there. All four corrected, keeping the honest note that older
   jars do need the flags, and that the launchers remain useful for the heap and
   Logback settings they also set.

3. The pom comment asked for a sync that nothing enforced.

   It said to keep the manifest's Add-Opens in step with .mvn/jvm.config and named
   JvmModuleFlagConsistencyTest as the canonical list -- but that test never read
   the manifest. It does now: the entry must equal the java.base opens from the
   master set exactly. A package dropped from it would otherwise fail only at
   runtime, on a user's machine, when LMDB is opened.

Also adds the CHANGELOG entries the four pulled commits deferred: the ICD
de-duplication, the self-sufficient fat jar, the disableAddressLookup fix, and the
jar-version guard.

Deliberately not done: an NVIDIA-specific CL_DEVICE_PCI_BUS_ID_NV path. It is now
unnecessary -- NVIDIA reports the Khronos extensions, which the generic path
already uses.

Verified: 126 tests across JvmModuleFlagConsistencyTest, OpenCLDeviceTopologyTest,
TuneConfigurationTest, ExampleRunScriptJarVersionTest, ConsumerJavaTest, MainTest
and the architecture rules. REUSE 487/487, spotless clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it PCIe

Two loose ends from making the fingerprint vendor-neutral.

The skip message said "PCIe {}" while printing a value that is now a
cl_khr_device_uuid on most hardware. It was accurate when the only source was
AMD's topology extension and is not any more.

More useful: the identity was only visible when a merge happened. With no merge
the log could not distinguish "no duplicate is present" from "no fingerprint
could be read at all" — and those call for opposite conclusions when someone is
checking whether de-duplication works on their machine. Every detected device now
reports its identity, or "unknown".

Verified: 54 tests across TuneConfigurationTest and OpenCLDeviceTopologyTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
De-duplication merged on a shared fingerprint alone. The failure modes are not
symmetric: a missing fingerprint costs nothing (both entries kept, which is what
happened before this feature existed), while a fingerprint that is wrongly
identical merges two distinct GPUs and silently halves a multi-GPU machine, with
nothing in the run reporting it.

That is not hypothetical exposure. Of the three fingerprint sources, only AMD's
topology extension has ever executed on real hardware — the one dual-ICD machine
available reports neither cl_khr_device_uuid nor cl_khr_pci_bus_info, so both
Khronos paths are unverified outside unit tests. A driver handing every device
the same UUID is exactly the defect that would not be noticed.

Merging now also requires the device name to match. The name stays a veto and
never becomes a merge signal — merging on names would still be wrong, since a rig
of identical cards shares one. The legitimate case is unaffected: one card seen
through two ICDs reports the same name, verified as gfx1100 on both platforms of
an AMD box. Should two ICDs ever name one card differently, the merge is skipped
and both are swept: a missed optimisation, not a loss.

Written test-first; the guard test was confirmed red against the previous
behaviour.

Also, unrelated to the above but overdue:

  - CLAUDE.md instructed ./mvnw seven times. There is no wrapper in this
    repository, so every one of those commands fails when followed literally. I
    hit this myself earlier in this branch's work. Switched to mvn, with the
    absence stated explicitly rather than left to be rediscovered.
  - The README's "Keys per producer" description now says to read it alongside
    "Producer blocked (queue full)": while that counter rises the consumer is the
    bottleneck, so the shares describe what the queue allowed each producer to
    deliver, not what the devices can do. Observed during the dual-GPU
    verification and easy to misread as a device comparison.

Verified: 81 tests across TuneConfigurationTest, OpenCLDeviceTopologyTest,
JvmModuleFlagConsistencyTest, ExampleRunScriptJarVersionTest,
ConfigFixturesParseTest and the architecture rules. REUSE 487/487, spotless clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he global winner

Found by counter-testing on a dual-GPU machine, and it was my bug: multi-device
sweeping was added on top of the extension logic without revisiting which winner
the extension consults.

extendSweepBeyondTheCandidates called pickWinner over the global results
accumulator. On a two-device run the integrated GPU won at the largest
keysPerWorkItem it was given and should have been extended, but was not — the
global winner was the discrete card's arm, comfortably inside its own range, so
the decision reported "nothing to extend". The slower device's reported optimum
is then a silent lower bound, which is the precise defect the extension exists to
remove.

A single-GPU machine cannot show this: there the global winner and the device
winner are the same arm. It took hardware with two devices of very different
speed.

The extension now receives the device's own arms. nextExtensionArmForDevice picks
the winner from that slice, so consulting another device's result is structurally
impossible rather than merely corrected. Written test-first; the new test was
confirmed red, and its counterpart pins that a device winning inside its range
still must not be extended.

Also qualifies the interference figure in docs/tuning-your-gpu.md §8. It cited
"~3×" from a single laptop pair. The same measurement on a desktop pair (AMD
gfx1036 beside an RX 7900 XTX) gives ~1.5%: 3.12 M keys/s solo against 2.07 M/s
alongside. Both are now shown together with the advice to measure your own pair —
one sample was never enough to state a ratio as fact.

Not acted on: the reported OOM on shipped defaults. The diagnosis that came with
it — "host result buffer scales as 2^batchSizeInBits x keysPerWorkItem, so
batch24/kpwi256 needs ~274 GB" — does not match the code. OpenClTask.dstSizeInBytes
is 4 + 108 x 2^batchSizeInBits and does not involve keysPerWorkItem at all: 0.42 GiB
at batch22, 1.69 GiB at batch24. The OOM is real and reproducible, but its cause is
not established, and capping grid sizes against a formula that is off by a factor of
keysPerWorkItem would cripple the tuner for a phantom. Asked for the stack trace and
the exact config instead.

Verified: 79 tests across TuneConfigurationTest, OpenCLDeviceTopologyTest,
ConfigFixturesParseTest and the architecture rules. REUSE 487/487, spotless clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OOM the dual-GPU machine hit is mine, and the cause is not what its first
diagnosis said. The reported model — host buffer scaling as 2^batchSizeInBits x
keysPerWorkItem, ~274 GB at the largest shipped corner — does not match
OpenClTask.dstSizeInBytes, which is 4 + 108 x 2^batchSizeInBits, off-heap, and
1.69 GiB at batch24. Acting on that model would have capped grid sizes against a
phantom. The follow-up stack trace settled it:

  OutOfMemoryError: Java heap space
    at OpenCLGridResult.readEntry(OpenCLGridResult.java:227)
    at ProducerOpenCL$ResultReaderRunnable.run(ProducerOpenCL.java:334)

With no GPU pre-filter every candidate is read back and enqueued, so the reader
materialises a full grid of PublicKeyBytes per queued batch and per reader thread.
It is a host-heap limit and it scales with 2^batchSizeInBits alone —
keysPerWorkItem is irrelevant, exactly as the code says.

Why the tuner walked into it: auto-discovered devices were built from
new CProducerOpenCL(), whose enableGpuFilter defaults to false. That default was
wrong here in three separate ways. The tuner builds a filter payload for
targetDatabaseEntries regardless — about a minute at 141 M entries — and then
never used it. It measured full-transfer throughput while claiming to describe the
pipeline the operator will run, which both the guide and the emitted
recommendation put behind a filter. And it transferred every candidate, which is
what exhausted the heap.

Emptying the shipped example's producerOpenCL list to demonstrate auto-discovery
is what moved it onto those defaults; the example had carried
enableGpuFilter: true until then. So the regression arrived with the change that
was meant to make the example simpler.

Auto-discovery now sets enableGpuFilter explicitly. A sweep that still runs
without one warns before starting, with the estimated host-heap cost against the
actual maximum heap — the JVM's own OutOfMemoryError names neither the setting nor
the grid size responsible, which is precisely why this was first read as a
device-memory problem. The threshold is deliberately eager (a quarter of the heap)
and the per-candidate estimate coarse: a sweep that dies twenty minutes in costs
more than a warning nobody needed.

Test fixture: the tuner tests now pin targetDatabaseEntries to 1000. With the
filter on, the 141 M default would have every OpenCL test build a genuine Binary
Fuse filter inside a 2 GiB Surefire fork — which is how this change first
announced itself, as an OOM in the test fork rather than in the code under test.

Also ignores *.hprof. pom.xml sets -XX:+HeapDumpOnOutOfMemoryError, so reproducing
this dropped multi-GB dumps into the working tree and nothing kept them out.

Verified: 133 tests across TuneConfigurationTest, ConsumerJavaTest, MainTest,
OpenCLDeviceTopologyTest, ConfigFixturesParseTest and the architecture rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…peline

Full shipped-default sweep (7x5 candidates up to batch24/kpwi256, secondsPerArm=20,
real targetDatabaseEntries=140964881) on 9fbe4bf, run with a bare `java -jar` and no
-Xmx override -- the exact scenario that previously OOM'd at arm 22/35 now completes
cleanly, because auto-discovered devices sweep with the GPU pre-filter enabled.

One row per arm, including the per-device sweep extensions. Per-device winners:
gfx1100 (RX 7900 XTX) at batch24/kpwi512 ~190 M/s, gfx1036 (iGPU) at batch23/kpwi256
~4.6 M/s; both winners sat on the kpwi edge and were extended past the candidate list,
each judged against its own device (not the global winner).

NOTE: these figures measure the FILTERED pipeline -- only FUSE_8 survivors are read
back to the host, which is what the tuner's recommendation actually describes. Earlier
numbers on this machine measured full candidate transfer and are NOT comparable.
Verification cost 6.60 us measured against the real 141M-entry database; out-of-line
(AMD_NOINLINE_HELPERS) kernel; machines.json already current (dry-run matches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g913gVNXb5wJfnZMM6E96
ryzen79800x3d8core-61g-win11 now has two tuner files, and their throughput
figures differ by an order of magnitude for the same card: the older one measured
a single device through the full-transfer path, the new one measures both GPUs
through the GPU-filtered path where only filter survivors cross back to the host.
That is a different pipeline, not a better result, and nothing in the directory
said so.

The older file stays. This directory's own rule is that stale rows are kept for
provenance rather than deleted, and the reviewer who raised the question was right
not to remove it unasked.

Also records what the file table left implicit: in a multi-device sweep the winner
flag is per device, so such a file carries one "yes" per GPU rather than one for
the file. Verified against the new data — each flagged row is in fact its own
device's maximum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bernardladenthin
bernardladenthin merged commit 78a6e1f into main Jul 26, 2026
10 of 15 checks passed
@bernardladenthin
bernardladenthin deleted the claude/statistics-keys-per-producer branch July 26, 2026 20:29
bernardladenthin added a commit that referenced this pull request Jul 27, 2026
…pocl-gpu-assume

Fix the CI failures from #320: SpotBugs findings and a GPU-only assumption on pocl
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant