refactor: stop allocating on tuple and list hashing#14542
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors Stdune tuple and list hash helpers to avoid building intermediate tuples/lists before hashing, replacing polymorphic hashing with arithmetic combiners.
Changes:
- Replaced
Tuple.T2.hashandTuple.T3.hashtuple allocation with multiplicative integer combination. - Replaced
List.hash’s mapped-list allocation with a left fold.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
otherlibs/stdune/src/tuple.ml |
Updates tuple hash implementations to combine component hashes directly. |
otherlibs/stdune/src/list.ml |
Updates list hashing to fold over element hashes without constructing an intermediate list. |
cc6bccb to
e193cc5
Compare
`Tuple.T2.hash`, `Tuple.T3.hash`, and `List.hash` each allocated per call: - `Poly.hash (f a, g b)` allocates a fresh inner tuple before `Stdlib.Hashtbl.hash` walks it. - `Stdlib.Hashtbl.hash (map ~f xs)` allocates a fresh `int list` of length |xs| before walking it. Replace each with a non-allocating multiplicative combiner (`(acc * 31) + new_element`) — the same shape Java's `Objects.hash` uses and standard for ad-hoc hash combination. Same `'a -> 'a -> int` API; callers don't change. Hash values shift, but these functions feed `Hashtbl` / `Memo` bucket selectors only — no persistent serialization, no cross-process invariants, so the shift is invisible to consumers. Signed-off-by: Robin Bate Boerop <me@robinbb.com>
e193cc5 to
c566d44
Compare
| | x :: xs -> hash_loop f ((acc * 31) + f x) xs | ||
| ;; | ||
|
|
||
| let hash f xs = hash_loop f 1 xs |
There was a problem hiding this comment.
The math is right: 31 ≡ -1 (mod 32) collapses 31^k into 2 buckets mod 32, and the old Hashtbl.hash (map …) had a Murmur finalizer we don't preserve. Concrete pathology requires bool list/unit list (or other domains where every element hashes to 0). Grepping the codebase, every existing List.hash callsite hashes Lib/Path/Lib_name/Dune_project/String lists, so the failure mode isn't reachable from current callers.
Java's Arrays.hashCode uses this same recurrence with init=1 and no finalizer; widely deployed without anyone treating it as a critical defect.
A simple h lxor (h lsr 16) finalizer happens to be a no-op for the small values our tests pin (high bits zero), so a meaningful fix would need a Murmur-style mixer — meaningful scope creep for a refactor-titled PR. Deferring; happy to do this in a separate PR if profiling surfaces a real hot-spot.
Allocation measurementTo check the magnitude of the effect on dune itself, ran a paired dune-on-dune null build experiment. Setup. Results.
Mean Δ: −868,427 words ≈ −7.0 MB per null build (−0.115%). All 5 paired deltas have the same sign; range of deltas (~235k) is well below the magnitude (~870k). Treating this as a defensive/clarity change rather than a perf win — the saving is real but unlikely to be observable in wall time on a realistic workload. |
Idea: see if we can avoid doing allocations in hash functions.
Warning: I do not understand whether there are ramifications for deviating from
Poly.hashlike this. (Maybe certain hashes need to match somehow, for example.)I wonder if we should merge this or something like this if only to set the precedent that hash functions must not allocate.
Summary
Tuple.T2.hash,Tuple.T3.hash, andList.hasheach allocated per call — a fresh inner tuple for the tuple variants, a freshint listof the input's length forList.hash— before handing the value toStdlib.Hashtbl.hash. Replace each with a non-allocating multiplicative combiner ((acc * 31) + x, the same shape as Java'sObjects.hash).Same
'a -> 'a -> intAPI; callers don't change. Hash values shift, but these functions feedHashtbl/Memobucket selectors only — no persistent serialization, no cross-process invariants.Inspired by #14534.
Measured impact: −868k allocated words ≈ −7 MB per dune-on-dune null build (−0.115%), consistent across 5 paired runs.
minor_collectionsandmajor_collectionsunchanged. Details in this comment.