Project

swiss_hash

0.0
The project is in a healthy, maintained state
Swiss Table hash map as a Ruby C extension
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

 Project Readme

SwissHash

Swiss Table hash map implementation as a Ruby C extension. The design follows the same broad family as Google's Abseil flat_hash_map, Rust's hashbrown, and Go 1.24 Swiss Tables, with Ruby-specific hashing, key preparation, GC integration, and a Hash-like API surface.

Installation

gem install swiss_hash

Usage

require "swiss_hash"

h = SwissHash::Hash.new
h["key"] = "value"
h["key"]          # => "value"
h.fetch("key")    # => "value"
h.delete("key")   # => "value"
h.stats            # => { capacity: 16, size: 0, ... }

SwissHash::Hash is intentionally not a subclass of Ruby's built-in Hash. Use to_h when you need a real Ruby Hash, and to_sh when you want a shallow SwissHash copy.

Performance Results

Benchmarks below were produced by benchmark.rb on Ruby 3.4.3 / arm64-darwin24.

Methodology: 10 runs × 21 measured iterations, 5 warmup iterations per run, IQR-filtered mean per run, interleaved Ruby/SwissHash measurements with alternating start order, and per-side coefficient of variation (±X.X%) reported to make noise visible.

N = 100,000

Operation Ruby Hash SwissHash Delta
Insert (sequential int) 6.324 ms (±0.5%) 5.023 ms (±0.6%) −20.57%
Insert (string keys) 16.485 ms (±4.6%) 10.386 ms (±3.0%) −37.00%
Insert (random int) 5.963 ms (±1.2%) 5.010 ms (±1.0%) −15.98%
Lookup (sequential int, 3x) 13.281 ms (±0.1%) 11.116 ms (±0.1%) −16.31%
Lookup (string keys, 3x) 20.561 ms (±3.8%) 21.535 ms (±4.9%) +4.74%
Delete + reinsert 25% 8.965 ms (±0.7%) 7.164 ms (±0.8%) −20.09%
Mixed (70% read / 20% write / 10% delete) 21.784 ms (±0.2%) 18.990 ms (±0.3%) −12.83%

N = 10,000

Operation Ruby Hash SwissHash Delta
Insert (sequential int) 0.578 ms (±1.1%) 0.510 ms (±1.1%) −11.82%
Insert (string keys) 1.522 ms (±3.5%) 0.992 ms (±1.6%) −34.80%
Insert (random int) 0.554 ms (±2.0%) 0.501 ms (±2.5%) −9.53%
Lookup (sequential int, 3x) 1.071 ms (±0.3%) 1.065 ms (±0.1%) −0.55%
Lookup (string keys, 3x) 1.710 ms (±1.3%) 1.563 ms (±1.4%) −8.58%
Delete + reinsert 25% 0.814 ms (±1.6%) 0.714 ms (±1.2%) −12.29%
Mixed (70% read / 20% write / 10% delete) 1.988 ms (±0.4%) 1.869 ms (±0.5%) −5.98%

N = 1,000

Ruby Hash uses an AR-table for small hashes, so very small integer-keyed workloads can still favour the built-in implementation. String-heavy workloads continue to be the strongest SwissHash case.

Operation Ruby Hash SwissHash Delta
Insert (sequential int) 0.056 ms (±0.7%) 0.057 ms (±0.9%) +1.88%
Insert (string keys) 0.156 ms (±2.5%) 0.102 ms (±0.9%) −34.78%
Insert (random int) 0.054 ms (±3.5%) 0.051 ms (±3.4%) −6.54%
Lookup (sequential int, 3x) 0.111 ms (±0.4%) 0.111 ms (±0.2%) +0.29%
Lookup (string keys, 3x) 0.181 ms (±0.3%) 0.150 ms (±0.4%) −17.10%
Delete + reinsert 25% 0.082 ms (±0.9%) 0.079 ms (±0.5%) −4.12%
Mixed (70% read / 20% write / 10% delete) 0.202 ms (±0.3%) 0.197 ms (±0.2%) −2.04%

Summary

  • SwissHash is faster on 6 of 7 operations at N=100k in the current benchmark run.
  • The strongest win is still string-key insertion: −34% to −37% across tested sizes.
  • Large integer-keyed inserts, delete/reinsert churn, and mixed workloads improved substantially after moving more Hash-like operations into C and keeping the hot paths lean.
  • String lookups are workload-sensitive: SwissHash wins at N=1k and N=10k, while the N=100k run is slightly slower than Ruby Hash within a noisier test band.
  • Ruby's built-in Hash remains excellent, especially for very small maps and cases that benefit from VM-level Hash specialization.

Memory Usage

For 100,000 integer keys in the current benchmark:

Implementation Reported memory
SwissHash 2,176 KB native + 4 GC slots
Ruby Hash 3 GC slots; native memory not directly measurable from this benchmark

Additional stats: load factor 76.3%, max load factor 87.5%, SIMD path reported as SWAR on the benchmark machine.

Features

  • Swiss Table probing: 7-bit H2 metadata, group probing, triangular probe sequence, and 87.5% max load factor.
  • Fast string-key path: wyhash for string keys, frozen string key preparation, ASCII-7bit equality shortcut, and direct memcmp when encodings are compatible.
  • Low GC pressure: keys and values are Ruby objects, while control bytes and slots live in contiguous native arrays.
  • Delete/reinsert friendly: tombstones are tracked and compacted to avoid pathological slowdown.
  • Hash-like API: basic accessors, enumeration, fetch helpers, merge/update/replace, filtering, transforming, slicing, inversion, and conversion helpers.
  • Native hot paths: performance-critical methods are implemented in C; small convenience wrappers live in Ruby where that does not affect the core benchmark paths.

API

hash = SwissHash::Hash.new(capacity = 16)

# Basic operations
hash[key] = value
hash.store(key, value)
hash[key]                  # returns nil if absent
hash.fetch(key)
hash.fetch(key, default)
hash.fetch(key) { |missing_key| ... }
hash.delete(key)           # returns old value or nil
hash.clear
hash.replace(other_hash)

# Merge/update
hash.merge(other_hash)
hash.merge(other_hash) { |key, old_value, new_value| ... }
hash.merge!(other_hash)
hash.update(other_hash)

# Enumeration
hash.each { |key, value| ... }
hash.each_pair { |key, value| ... }
hash.each_key { |key| ... }
hash.each_value { |value| ... }
hash.keys
hash.values
hash.to_a

# Query helpers
hash.size                  # also: length
hash.empty?
hash.key?(key)             # also: has_key?, include?, member?
hash.value?(value)         # also: has_value?
hash.key(value)            # first key for value, or nil
hash.assoc(key)
hash.rassoc(value)
hash.values_at(*keys)
hash.fetch_values(*keys)
hash.dig(key, *path)
hash.count                 # Enumerable-compatible

# Filtering and transforms
hash.slice(*keys)
hash.except(*keys)
hash.select { |key, value| ... }    # also: filter
hash.select! { |key, value| ... }   # also: filter!
hash.reject { |key, value| ... }
hash.reject! { |key, value| ... }
hash.delete_if { |key, value| ... }
hash.keep_if { |key, value| ... }
hash.compact
hash.compact!
hash.transform_keys { |key| ... }
hash.transform_keys! { |key| ... }
hash.transform_values { |value| ... }
hash.transform_values! { |value| ... }
hash.invert
hash.shift
hash.flatten(level = 1)

# Conversion
hash.to_h                  # returns a Ruby Hash
hash.to_sh                 # returns a shallow SwissHash copy

# Maintenance / debugging
hash.compact_storage!      # drop tombstones without changing values
hash.stats                 # => { capacity:, size:, num_groups:, load_factor:,
                           #      memory_bytes:, growth_left:, tombstones:,
                           #      simd:, layout: }

Compatibility notes

SwissHash aims to cover the practical subset of Hash that is useful for a fast native hash map, but it is not a drop-in replacement for every Ruby Hash semantic.

Not currently supported:

  • default values and default blocks from Hash.new(default) / Hash.new { ... }
  • compare_by_identity
  • full insertion-order guarantees
  • every rarely used method from Ruby's full Hash API

Usage Recommendations

Use SwissHash when:

  • keys are mostly strings and insert speed matters;
  • the map commonly holds 10,000+ entries;
  • workloads include deletes and reinserts;
  • predictable native memory layout and lower Ruby-object churn are useful.

Stick with Ruby's built-in Hash when:

  • the hash is small and mostly lookup-heavy with integer keys;
  • you depend on exact Ruby Hash semantics such as defaults, insertion order, compare_by_identity, or the complete standard API;
  • the code path benefits from VM-level Hash#[] specialization more than from the underlying table layout.

Architecture

Swiss Table core

  • Open addressing with 7-bit H2 metadata byte per slot; group matching rejects non-matching slots in batches.
  • Group size 16 on SSE2 and group size 8 on portable SWAR. On the benchmarked Apple Silicon machine the active path is SWAR.
  • Triangular probingi(i+1)/2 — over power-of-two group counts.
  • Max load factor 87.5% (7/8).

Ruby-specific adaptations

  • wyhash for string keys.
  • Fibonacci multiplicative hash for Fixnum and Symbol keys.
  • Frozen string key preparation to avoid later key mutation surprises.
  • ASCII-7bit and encoding-index equality fast paths before falling back to Ruby-compatible string comparison.
  • Inline RTYPEDDATA_DATA on hot methods ([], []=, delete, key?) to avoid repeated typed-data checks.
  • Prefetch of slot groups after control-byte load so data fetch overlaps with match extraction.

Memory layout

  • Separate control-byte array and slot array.
  • Native arrays are allocated outside Ruby's object heap; keys and values are still marked for GC.
  • Slot memory is not zero-initialized on allocation; slots are read only after their control byte marks them live.

Build

bundle install
bundle exec rake compile

Test

bundle exec ruby test/hash_api_test.rb
bundle exec ruby test/string_key_mutation_test.rb

Benchmarking

bundle exec ruby benchmark.rb

The benchmark includes a smoke test before timing and prints the active SIMD/SWAR path in the memory section.

Profiling

For profiling on macOS:

bundle exec ruby simp.rb            # runs an infinite lookup loop and prints PID
sample <PID> 60 -f /tmp/swiss.sample
filtercalltree /tmp/swiss.sample | head -100

Changelog

See CHANGELOG.md.

Design References

License

MIT