PGO, HashMaps and build inputs

kpcyrd kpcyrd at archlinux.org
Tue Sep 15 04:34:29 UTC 2026


Hello!

following up on an IRC discussion about PGO profiling on the build server 
causing undeterministic binaries, I made a simple repository to demo this - 
there's other ways to achieve the same effect (like race-y readiness of e.g. 
file descriptors due to disk speed, causing different code branches to be taken).

https://github.com/kpcyrd/pgo-hashmap-unreproducible

PGO stands for "profile guided optimizations" and works by building an 
instrumented binary `gcc -O2 -fprofile-generate=profile`, running test cases on 
the binary, then using the measured code paths to hint to the compiler which 
paths are hot and which ones are cold, building the release binary with `gcc -O2 
-fprofile-use=profile`.

To manually hint control flow probability, the following options are available:
- https://doc.rust-lang.org/std/hint/fn.cold_path.html
- https://clang.llvm.org/docs/LanguageExtensions.html#builtin-expect

Back to PGO, the specific example I went for are HashMaps. As a quick recap of 
how they work, instead of putting values in a list and having to scan the entire 
list every time I want to find a value, a hashmap defines a number of buckets. 
To decide which bucket to use for which key, the key is hashed using a very fast 
hash function (it doesn't need to be cryptographically secure, it just needs to 
evenly distribute across the buckets as fast as possible).

     map = [bucket, bucket, bucket, ...]
     bucket_index = H(key)
     map[bucket_index]

But what's inside the bucket slots? Ideally each slot only ever holds one value, 
but for the worst-case scenario of two keys hashing to the same bucket, we still 
have a list in each slot that we are going to scan through. This, obviously, 
doesn't perform well and should only happen rarely.

At some point people figured out, since the hash function is often known, they 
could come up with a list of keys that would all get sorted into the same 
bucket, essentially turning the hashmap into a list. When putting all those 
colliding keys into a POST request, they could waste the servers CPU time 
effectively.

To stop this from happening people switched to a keyed hash function. The 
program generates a random number (once per process is often enough), this 
number is supposed to stay secret and is used to key the hash function. This 
needs to happen at runtime obviously, we can't just put this number into the 
binary for anyone to see.

     secret = random()
     map = [bucket, bucket, bucket, ...]
     bucket_index = keyed_H(key, secret)
     map[bucket_index]

Without knowing the secret value, it's not possible anymore to reliably generate 
keys that all sort into the same bucket.

However, if we insert 1.000 keys, the value of random() is now going to 
influence the number of collisions we are going to get, causing different 
weights on the code branches executed. The compiler may decide to optimize the 
binary differently when more collisions happened, versus one with less collisions.

The repo contains a diffoscope.txt showing the different assembly generated.

This is what the repository demonstrates. It doesn't implement a full hashmap, 
rather a toy hashset, but it does it good enough to demonstrate how code that 
produces binaries that are perfectly reproducible without PGO, become 
unreproducible with PGO enabled.

Some projects benefit heavily from optimizations possible with those profiles 
(probably too much to ignore). I suggest they could be a documented build input 
however, instead of attempting to generate them on the fly. Either as a regular 
build input like source code, or as part of "the build environment", meaning 
it's an additional output of the initial build, like buildinfo files.

People liked the example repo (to have something to play around with), so I 
figured it might be worth sharing on the list too.

See you on the summit next week. :)

cheers,
kpcyrd


More information about the rb-general mailing list