#12509·xgboost

Portable RNG state: measurements on the Philox throughput question from #12485

Author: sinhaparth5Created Aug 28, 2026Updated Aug 28, 2026

#12459 is still open. A Booster trained on Linux fails to unpickle on Windows, because Context::rng_ is a std::mt19937 and its serialized form is not portable across standard libraries. libstdc++ writes 624 raw words plus a position; libc++ and the MSVC STL write 624 rotated words with none. Windows fails loudly on a Linux checkpoint, macOS loads a structurally valid but rotated state and fails silently.

I opened #12485 for this and closed it after review. Two of the three points there were clearly right: the SerializableRandomEngine wrapper was unnecessary, since Philox's stream operator is well defined, and there was an inefficiency in the multiplies. The third, "the performance here is 1/10 of mt", I could not reproduce, and since it was the reason the approach was set aside, I went and measured it properly rather than assert otherwise.

Raw data and harness: https://github.com/sinhaparth5/xgboost/tree/philox-multiply-probe/ci_probe

The 1/10 figure appears at -O0

Sapphire Rapids (Xeon 8481C), dedicated vCPU, pinned, 15 repetitions, median CV under 0.3%. ns per draw, steady-state sequential:

build std::mt19937 Philox ratio
g++ -O0 11.09 161.75 0.07x
g++ -O2 2.35 3.96 0.59x
g++ -O3 1.84 3.07 0.60x
clang -O0 7.89 195.22 0.04x
clang -O2 2.50 3.74 0.67x
clang -O3 2.36 3.74 0.63x

Unoptimised, UMul64 stays an out-of-line call made twice per lane per round, and the ratio lands between 0.04x and 0.10x. Optimised, it is 0.59-0.67x. CMake emits no optimisation flags at all when CMAKE_BUILD_TYPE is unset.

Optimised Philox is still slower than mt19937 at drawing numbers in a loop, by roughly a quarter to a third. That cost is there and I am not arguing it away. It is not an order of magnitude.

The multiply inefficiency is the duplicate product

MulHi and MulLo each compute the same a * b. Multiply instructions in the generated block function:

compiler as-is MulHi specialised single-product MulHiLo
GCC 13.3 -O3 20 20 20
clang 18.1.3 -O3 40 40 20
MSVC 19.51 /O2 24 20 13

Ten rounds times two lanes means 20 is one multiply per lane per round. GCC already folds UMul64 and CSEs the duplicate away, so a hand-written wide-multiply specialisation changes nothing there. clang folds but does not CSE, paying double. Taking both halves from a single product fixes all three, and is worth 1.22x on clang in wall time (0.63x -> 0.77x at -O3). It is ~15 lines and produces a bit-identical stream, verified against [rand.predef]'s 10000th-draw constant 1955073260 and 500k draws across variants.

XGBoost does not use the engine in steady state

ColumnSampler::ColSample builds a fresh engine on every call:

cpp
auto seed = ctx->Rng()();
RandomEngine rng(seed);
std::shuffle(new_features.HostVector().begin(), ..., rng);

So the relevant cost is construction plus a few hundred draws, not a long sequential run. Constructing std::mt19937 initialises 624 words. Constructing Philox sets a key and a counter. ns per ColSample call, GCC -O3:

n features std::mt19937 Philox
16 1621 59 27.3x faster
64 1739 235 7.4x faster
256 2201 854 2.6x faster
1024 4014 3368 1.19x faster
4096 12359 13681 0.90x slower

mt19937 goes from 1621 ns to 1739 ns for four times the draws. Almost all of it is the state init. clang agrees within a few percent. The crossover is past ~2000 features, so for typical feature counts the counter-based engine is ahead at the site that actually matters, and the steady-state deficit does not reach the workload.

What I would like to propose

RandomEngine becomes a Philox engine following [rand.eng.philox], so rng_state serializes as a key and a counter and survives a platform hop. That is #12485 without the wrapper and with the single-product multiply, which addresses both of the points that were right.

End-to-end training time is in a comment below: 0.37% apart on a two-second run, which I read as no regression rather than a win. And I recognise the closing comment mentioned reworking the PRNG at a higher level; if that is still the plan, I would rather contribute to it than duplicate it, so say the word and I will hold off. Mainly I did not want #12459 to sit open on the assumption of a 10x cost that I could not find outside an unoptimised build.