armrx: 400+ Commits into an AArch64 Miner
armrx started with a concrete goal: build a RandomX v1 miner for AArch64 Linux, with my own implementation of the hashing pipeline and a JIT compiler that emits native ARM instructions.
The checkout has reached 442 commits. Some add features, some fix bugs, and quite a few record an experiment before undoing it. Taken together, they tell the story of learning how to measure a small ARM machine—and how easily a convincing explanation can outrun the evidence.
The result is a C++20 miner with interpreted and JIT execution, pool connectivity, multiple workers, and memory modes for constrained devices. The most interesting part of building it has been finding out why a change that should help sometimes does nothing, or makes everything worse.
A demanding workload on a small machine
RandomX is a proof-of-work algorithm designed around general-purpose CPUs. Its virtual machine executes generated programs containing integer operations, floating-point arithmetic, branches, and memory accesses. A JIT compiler translates those programs into native machine code.
Much of my performance work used a Lenovo device with a Snapdragon 415: eight Cortex-A53 cores, 2 GiB of RAM, and postmarketOS. Under the tested kernel configuration, it ran at an estimated fixed clock of roughly 765 MHz. The expected CPU frequency controls were absent, so choosing a different governor was not an available shortcut.
Memory shaped the project just as much as the CPU. RandomX's fast mode requires a shared dataset of 2,080 MiB, before accounting for the rest of the process and operating system. Light mode uses a 256 MiB shared cache and derives dataset items as needed. Both produce the same result, with very different execution costs. On this device, light mode was the practical starting point.
Building the miner meant connecting Blake2b, AES operations, cache initialization, dataset generation, VM execution, native code generation, and pool communication. Every boundary could introduce a bug. Some of the hardest bugs survived agreement between multiple parts of my own implementation.
When two implementations agree on the wrong answer
One early failure is worth remembering: the interpreter and JIT produced identical hashes, but those hashes disagreed with the RandomX reference.
That narrowed the investigation to code shared by both execution paths. The eventual fixes involved AES byte ordering, column permutations, and the order of operations in the ARM hardware AES path.
There was also a problem with the test itself. An expected value had been derived from the implementation being tested. It confirmed that the output stayed the same; it did not establish that the output was correct.
The hardware AES issue was particularly instructive. Feeding the real round key directly into the ARM instruction applied it at the wrong point for the required transform. That path was removed, then later restored with the appropriate compensation: use a zero key for the instruction and apply the real key at the end of the sequence. The corrected hardware and software paths were compared across 60,000 random blocks on the device.
This changed how I treated verification. Agreement between the interpreter and JIT is useful evidence, but shared code can give them shared mistakes. Independent reference results have to be part of the process.
Fewer instructions, slower mining
One optimization replaced sequences that construct constants in registers with loads from a literal pool. It was a reasonable idea: shorten the generated code by loading a stored value.
The modified version passed the device correctness checks. Then the benchmark rejected it.
With one worker pinned to the same core, the baseline produced 4.49 hashes per second in both recorded runs. The modified version produced 3.59 and 3.79 H/s—a regression of roughly 16–20%. Hardware counters also showed substantially more cache misses.
The shorter instruction sequences had introduced additional memory loads. On the tested workload and processor, the cost of those loads outweighed the benefit. I reverted the change and kept the experiment record.
Another experiment made the opposite point. Adjusting immediate construction to leave useful spacing around dependent multiply operations produced a documented single-worker improvement of about 7.1%. However, an eight-worker A/B found no measurable throughput benefit from the same padding.
An optimization can be correct and useful without helping every configuration. Worker count belongs in the result, alongside the processor and build settings.
The win that changed nothing
Perhaps the most uncomfortable correction involved a proposed change to the superscalar generator's timing model.
An initial reading suggested an improvement in stall behavior. Later review followed the actual scheduling arguments and found that the modified condition could not change the outcome. Comparing generated programs confirmed it: the before and after versions produced byte-identical programs across the recorded sample.
The performance explanation had been attached to a no-op. The apparent improvement was noise, and the change was reverted.
I used AI coding and review tools throughout the project, including for optimization proposals and experiment analysis. This episode captures why their explanations still needed independent checks. A detailed argument about a processor pipeline is a hypothesis until the emitted code and the measurements support it.
The repository eventually acquired a ledger recording which approaches were adopted, rejected, or still open. It also distinguishes a failed implementation from an entire family of ideas. That record helps keep an attractive but already-tested proposal from returning without a new reason to try it.
Spending memory to avoid repeated work
The partial dataset approach gave the project a practical memory tradeoff. With --dataset-mb=512, armrx caches a prefix of the dataset. Items inside that prefix can be loaded directly; others still use on-demand derivation.
Recorded runs on the Lenovo reached roughly 30–32 H/s in steady pool mining with this configuration, compared with around 26 H/s in plain light mode. The cached prefix came with an initial fill wait of roughly 175 seconds in the documented setup.
That startup delay matters. Attempts to mine while competing with the fill work did not earn their keep on this device. Letting the fill complete made more sense for the sustained workload being measured.
These are results from specific runs on one setup. They are not a benchmark for every AArch64 processor, and the startup cost belongs next to the steady-state number.
Keeping the simpler path
Some experiments ended with a much smaller decision. Interleaving AES finalization for one hash with scratchpad filling for the next sounded promising, but the later end-to-end comparison measured 27.01 H/s for interleaving and 27.25 H/s for sequential execution. The difference was treated as noise.
The sequential pair became the default. The interleaved implementation remained available behind an experimental build option for future measurements.
I like that outcome. An experiment can justify removing complexity even when it does not deliver a speedup.
What I want to measure next
The hardware evidence is still concentrated on a small collection of ARM devices. A choice that suits the Cortex-A53 may behave differently on another core, with different caches, instruction timing, and memory bandwidth.
The repository now includes open experiments for gathering those comparisons. One asks whether the immediate-padding choice should vary by processor. A useful result includes the device, operating system, worker count, clock behavior, and both A/B measurements—not just the faster number.
There is also ongoing work around the less visible parts of a miner: job changes, dataset lifetime, shutdown, and pool reconnection. A long-running process has to remain correct when its inputs and state change, not only during a steady benchmark.
After more than 400 commits, my next performance claim still has to answer the same questions: did the hashes remain correct, did the intended code actually change, and did the improvement survive a repeat run on the device?