Skip to content

IntMap is 2.3x slower than stock hxcpp 4.3.53 (map_int benchmark) #231

Closed

Description

@dimensionscape

The benchmark suite (#151) measures IntMap at 0.44x the speed of stock hxcpp 4.3.53: 54.8M map operations/s against 124M. The gap is the same in all three processes, and well outside the run-to-run spread (±1.2% and ±0.7%).

What the benchmark does (bench/src/MapBenchmarks.hx, MapInt): a fresh IntMap<Int> per cycle, 200,000 keys; 200,000 sets, 200,000 hits, 200,000 misses, 100,000 removes, then iterates the 100,000 values left. 6 cycles per sample. Keys are distinct non-sequential ints computed in setup(), so only map work is timed. Checksums match stock, so both runtimes do the same work.

Not known yet: which of the operations is slower. The first step is to time set, get, exists, remove and iteration separately against stock.

Possibly related, unconfirmed: #189 (initial reservation ignored during storage conversion, repeated rehashing) and #225 (resize hysteresis). Both concern libhxcpp's hash code, which differs from stock's.

Measured on perf/benchmark-suite at bf583e5, which includes the #230 heap fix: Windows, Ryzen 9 9950X, MSVC 19.44, 64-bit, Haxe 4.3.7, 3 processes x 10 samples. The full table is in that branch's README.

Reproduce (on perf/benchmark-suite until the suite reaches main):

cd bench
haxe --run Run --only map_int

Done when map_int is at least level with stock on Windows and Linux.

Activity

  1. dimensionscape commented on Oct 20, 2026

    @dimensionscape
    MemberAuthor

    Progress on perf/benchmark-suite (not on main yet). map_int is now 0.5x stock (was 0.44x). What's known:

    Fixed

    Most of the remaining gap is the benchmark's key pattern, not a defect. The keys are (i*2579)^0x2b5f3c1d. With stock's identity hash those land in distinct buckets, and misses land in empty buckets: a perfect hash. libhxcpp mixes the bits (to avoid the collisions fixed in rc.1), which scatters them. Per phase, ms per cycle, 200,000 keys:

    Keys set get miss remove iterate
    benchmark, stock / lib 2.0 / 4.2 1.47 / 3.9 0.36 / 2.05 0.26 / 0.92 2.0 / 3.0
    random, stock / lib 6.4 / 5.0 3.95 / 3.85 2.7 / 2.2 1.2 / 0.93 2.6 / 3.0
    strided i<<10, stock / lib 380 / 2.8 200 / 2.1 407 / 0.6 102 / 0.57

    With random keys libhxcpp is level or faster on every phase except iteration. With strided keys stock is up to 680x slower. Going back to an identity hash would trade that away to win this one benchmark, so I'm not doing that.

    Iteration (3.0 vs 1.8 ms on scattered keys) is a GC threshold effect, not iterator or marking cost. With the map dropped before the walk, libhxcpp walks faster than stock (1.65 vs 1.8). GC event logs show that per rep libhxcpp's heap ends one 1 MB group over the 20 MB minimum target (21.3 MB) and collects once during the walk, while stock sits just under it (19.4 MB) and adds a group instead. The allocator and sizing code are identical; the difference is about 1 MB more allocated per rep, landing right at the edge. I'm leaving the GC tuning alone for this.

    Next: add map_int_random and map_int_strided benchmarks so the README shows both sides of the hash trade-off, rather than changing map_int's keys.

  2. dimensionscape commented on Oct 20, 2026

    @dimensionscape
    MemberAuthor

    The remaining gap is closed on perf/benchmark-suite (b245698): map_int is now 1.04x stock hxcpp 4.3.53, up from 0.5x.

    Cause. Every Int key went through HashMixInt. The mix protects strided keys (i << 10 and similar), but it scatters arithmetic keys (ids, offsets, i * odd), which stock's identity hash spreads with no collisions at all. The benchmark's keys are arithmetic, and so are many keys in real programs.

    Fix. Int maps start on the identity hash, like stock. A map switches to the mix for good, rehashing in place once, when either:

    • an insert walks a chain of 16 entries, which practically never happens with a good hash at load <= 1, or
    • after growth, entries average more than 2 per used bucket (a good hash gives at most about 1.6).

    Int64 and object keys are unchanged.

    Per-phase probe (ms per cycle, 200,000 keys, after Gc.run(true)), stock / before / now:

    Keys set get miss remove walk
    benchmark (i*2579)^c 2.2 / 4.2 / 2.0 1.5 / 3.9 / 1.7 0.39 / 2.05 / 0.43 0.27 / 0.92 / 0.25 1.9 / 3.0 / 2.3
    strided i<<10 387 / 2.8 / 2.2 205 / 2.1 / 1.6 421 / 0.6 / 0.55 104 / 0.57 / 0.31 1.8 / – / 2.4

    Random keys still beat stock on set, get and miss (4.0 / 3.3 / 2.1 against 6.4 / 4.0 / 2.6).

    Tests. The new test/regression/IntMapHashSwitch covers keys that never switch, keys that switch on a long chain, and keys that switch at growth. Each pattern runs at 10 to 100,000 keys with updates, removes, misses, iteration, emptying and refilling. It also fails if strided keys take more than 10 s, which is what happens if the switch breaks. MapShrinkRegrow and the test/haxe suite (667 assertions, generational GC build) pass.

    Left: walk is still about 0.4 ms slower, which is the 20 MB working-target edge effect described above. I'll leave this open until the branch reaches main and Linux numbers confirm it.

  3. dimensionscape commented on Oct 20, 2026

    @dimensionscape
    MemberAuthor

    Fixed in e97f84c: int maps start on the identity hash and switch to the mix on collisions. map_int is now 1.04x of hxcpp 4.3.53 (see BENCHMARKS.md).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions