swiss_hash 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15c6c585465ffcd0e8a399df625655da292ec1c619e6283eb86cd4e27ac9c682
4
- data.tar.gz: e9b0e51c0e8a8e7279ba1ccbe32f5c3e9fa8fddeae5ea55328d9f149acbabf08
3
+ metadata.gz: b9d4f872535c0a7e7558f6c7950a2fb113263ab9b75cd2397bff72ee4185c295
4
+ data.tar.gz: ad110fb4e54d662181d3e49546eec833a953be7b87debae477837ad8e50408a5
5
5
  SHA512:
6
- metadata.gz: 2587c907c6d77be5049d0b08edfa8f8c6c03b0503eb380e4577cf64dc722370e9fb1841778f55d86869d8dcc6d61517cfe8a199521eb1e5926783fd3d1e8deda
7
- data.tar.gz: 9222dcf296f098faa0774e9b54b67aab36e40efdf5576049eac2d79880d1b61f55f27eb39239e71996c41a4d239d537e9cf42e2f1154c9c9ea2026901f77c302
6
+ metadata.gz: 85cecddcb36ff970d62bd053bb78c7b42f0f15290276f64326ecc053236ca6ff473ed016933dd848ce9d9f4e5b63efdebb014140a84b22436e9bb9614567b4cf
7
+ data.tar.gz: 2cf36fed40554508684932d155bad203b4da18d9b808d2a8317b5f0e46b5a2e3dee9ebe3ab963ecdbd75406e99075d816d2dcf9b1277b562c6b421a845b1d8ec
data/CHANGELOG.md ADDED
@@ -0,0 +1,82 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3 - 2026-08-18
4
+
5
+ - NEON group matching on ARM64 (SSE2 on x86_64, SWAR elsewhere)
6
+ - Single allocation for control bytes and slots
7
+ - Faster Fixnum `[]` / `[]=`
8
+ - 7-bit strings of different encodings hash like Ruby Hash (`"abc"` and `"abc".b` are the same key)
9
+ - Rehash is GC-safe; `#hash` / `#eql?` that mutate the same table raise instead of corrupting it
10
+ - `initialize` no longer leaks on reuse; OOM no longer leaves a half-built table
11
+
12
+ ## 0.1.2 - Unreleased
13
+
14
+ Documentation and API polish for the first public release candidate.
15
+
16
+ ### Added / changed
17
+
18
+ - Expanded the Hash-like API surface:
19
+ - `fetch`, `values_at`, `fetch_values`
20
+ - `merge`, `merge!`, `update`, `replace`
21
+ - `to_h`, `to_sh`, `to_a`
22
+ - `slice`, `except`, `invert`, `assoc`, `rassoc`, `shift`
23
+ - `delete_if`, `keep_if`, `select`, `select!`, `filter`, `filter!`, `reject`, `reject!`
24
+ - `compact`, `compact!`, `transform_keys`, `transform_keys!`, `transform_values`, `transform_values!`
25
+ - `value?`, `has_value?`, `key`, `dig`, `count`, `flatten`, `==`, `eql?`, `hash`, `inspect`
26
+ - Moved the important Hash-like operations into the C extension instead of doing the expensive parts in Ruby.
27
+ - Kept `to_h` as the Ruby-compatible conversion to a real `Hash`; added `to_sh` as the explicit shallow SwissHash copy.
28
+ - Updated the README with the latest benchmark output from Ruby 3.4.3 / arm64-darwin24.
29
+ - Documented compatibility boundaries: no default values/default blocks, no `compare_by_identity`, no full insertion-order guarantee, and not the entire Ruby `Hash` API yet.
30
+
31
+ ### Benchmark update
32
+
33
+ Struck-through values are the previous README numbers. The value after the arrow is the current benchmark result.
34
+
35
+ #### N = 100,000
36
+
37
+ | Operation | Ruby Hash | SwissHash | Delta |
38
+ |---|---:|---:|---:|
39
+ | Insert (string keys) | ~~17.6 ms~~ → 16.485 ms | ~~11.3 ms~~ → 10.386 ms | ~~−35.8%~~ → **−37.00%** |
40
+ | Delete + reinsert 25% | ~~9.8 ms~~ → 8.965 ms | ~~8.9 ms~~ → 7.164 ms | ~~−9.0%~~ → **−20.09%** |
41
+ | Insert (sequential int) | ~~7.0 ms~~ → 6.324 ms | ~~6.4 ms~~ → 5.023 ms | ~~−8.7%~~ → **−20.57%** |
42
+ | Mixed (70/20/10 R/W/D) | ~~21.4 ms~~ → 21.784 ms | ~~19.6 ms~~ → 18.990 ms | ~~−8.6%~~ → **−12.83%** |
43
+ | Insert (random int) | ~~6.7 ms~~ → 5.963 ms | ~~6.4 ms~~ → 5.010 ms | ~~−3.5%~~ → **−15.98%** |
44
+ | Lookup (string keys, 3x) | ~~20.3 ms~~ → 20.561 ms | ~~20.8 ms~~ → 21.535 ms | ~~+2.5%~~ → +4.74% |
45
+ | Lookup (sequential int, 3x) | ~~11.7 ms~~ → 13.281 ms | ~~12.2 ms~~ → 11.116 ms | ~~+4.4%~~ → **−16.31%** |
46
+
47
+ #### N = 10,000
48
+
49
+ | Operation | Ruby Hash | SwissHash | Delta |
50
+ |---|---:|---:|---:|
51
+ | Insert (string keys) | ~~1.63 ms~~ → 1.522 ms | ~~1.10 ms~~ → 0.992 ms | ~~−32.8%~~ → **−34.80%** |
52
+ | Lookup (string keys, 3x) | ~~1.71 ms~~ → 1.710 ms | ~~1.62 ms~~ → 1.563 ms | ~~−5.2%~~ → **−8.58%** |
53
+ | Mixed (70/20/10 R/W/D) | ~~1.93 ms~~ → 1.988 ms | ~~1.90 ms~~ → 1.869 ms | ~~−1.6%~~ → **−5.98%** |
54
+ | Delete + reinsert 25% | ~~0.91 ms~~ → 0.814 ms | ~~0.89 ms~~ → 0.714 ms | ~~−2.0%~~ → **−12.29%** |
55
+ | Insert (sequential int) | ~~0.66 ms~~ → 0.578 ms | ~~0.67 ms~~ → 0.510 ms | ~~+2.5%~~ → **−11.82%** |
56
+ | Lookup (sequential int, 3x) | ~~1.05 ms~~ → 1.071 ms | ~~1.12 ms~~ → 1.065 ms | ~~+6.0%~~ → −0.55% |
57
+ | Insert (random int) | previously not listed → 0.554 ms | previously not listed → 0.501 ms | **−9.53%** |
58
+
59
+ #### N = 1,000
60
+
61
+ | Operation | Ruby Hash | SwissHash | Delta |
62
+ |---|---:|---:|---:|
63
+ | Insert (string keys) | ~~0.183 ms~~ → 0.156 ms | ~~0.118 ms~~ → 0.102 ms | ~~−35.6%~~ → **−34.78%** |
64
+ | Lookup (string keys, 3x) | ~~0.184 ms~~ → 0.181 ms | ~~0.155 ms~~ → 0.150 ms | ~~−15.5%~~ → **−17.10%** |
65
+ | Insert (sequential int) | ~~0.063 ms~~ → 0.056 ms | ~~0.074 ms~~ → 0.057 ms | ~~+17.5%~~ → +1.88% |
66
+ | Delete + reinsert 25% | ~~0.094 ms~~ → 0.082 ms | ~~0.102 ms~~ → 0.079 ms | ~~+8.2%~~ → −4.12% |
67
+ | Insert (random int) | previously not listed → 0.054 ms | previously not listed → 0.051 ms | −6.54% |
68
+ | Lookup (sequential int, 3x) | previously not listed → 0.111 ms | previously not listed → 0.111 ms | +0.29% |
69
+ | Mixed (70/20/10 R/W/D) | previously not listed → 0.202 ms | previously not listed → 0.197 ms | −2.04% |
70
+
71
+ ### Memory update
72
+
73
+ | Metric | Previous README | Current benchmark |
74
+ |---|---:|---:|
75
+ | SwissHash native memory, N=100,000 | ~~2,176 KB + 4 GC slots~~ | 2,176 KB native + 4 GC slots |
76
+ | Ruby Hash | ~~managed via GC slots / not directly measurable~~ | 3 GC slots; native memory not directly measurable |
77
+ | Load factor | ~~76.3%~~ | 76.3% |
78
+ | SIMD path | not shown in table | SWAR |
79
+
80
+ ## 0.1.1 and earlier
81
+
82
+ Pre-release iterations focused on the initial Swiss Table C extension, Ruby object/GC integration, string-key fast paths, deletion/tombstone handling, and the first benchmark harness.
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # SwissHash
2
2
 
3
- Swiss Table hash map implementation as a Ruby C extension. Based on the design principles from Google's [Abseil](https://abseil.io/about/design/swisstables) flat_hash_map, Rust's [hashbrown](https://github.com/rust-lang/hashbrown), and [Go 1.24 Swiss Tables](https://go.dev/blog/swisstable), with architecture adapted for Ruby's object system.
3
+ Swiss Table hash map implementation as a Ruby C extension. The design follows the same broad family as Google's [Abseil](https://abseil.io/about/design/swisstables) `flat_hash_map`, Rust's [hashbrown](https://github.com/rust-lang/hashbrown), and [Go 1.24 Swiss Tables](https://go.dev/blog/swisstable), with Ruby-specific hashing, key preparation, GC integration, and a Hash-like API surface.
4
4
 
5
5
  ## Installation
6
6
 
7
- ```
7
+ ```bash
8
8
  gem install swiss_hash
9
9
  ```
10
10
 
@@ -15,72 +15,86 @@ require "swiss_hash"
15
15
 
16
16
  h = SwissHash::Hash.new
17
17
  h["key"] = "value"
18
- h["key"] # => "value"
19
- h.delete("key")
20
- h.stats # => { capacity: 16, size: 0, ... }
18
+ h["key"] # => "value"
19
+ h.fetch("key") # => "value"
20
+ h.delete("key") # => "value"
21
+ h.stats # => { capacity: 16, size: 0, ... }
21
22
  ```
22
23
 
24
+ `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.
25
+
23
26
  ## Performance Results
24
27
 
25
- Benchmarks on Ruby 3.1.7 / arm64-darwin24 (Apple Silicon, NEON SIMD).
28
+ Benchmarks below were produced by `benchmark.rb` on Ruby 3.4.3 / arm64-darwin24.
26
29
 
27
- Methodology: 21 iterations per test, 5 warmup runs, IQR-filtered mean, **interleaved Ruby/SwissHash measurements** per iteration with alternating start order to cancel out thermal drift and scheduling noise. Per-side coefficient of variation reported to distinguish real deltas from noise.
30
+ Methodology: 6 runs × 17 measured iterations, 4 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.
28
31
 
29
32
  ### N = 100,000
30
33
 
31
34
  | Operation | Ruby Hash | SwissHash | Delta |
32
- |---|---|---|---|
33
- | Insert (string keys) | 17.6 ms | 11.3 ms | **−35.8%** ⚡ |
34
- | Delete + reinsert 25% | 9.8 ms | 8.9 ms | **−9.0%** |
35
- | Insert (sequential int) | 7.0 ms | 6.4 ms | **−8.7%** |
36
- | Mixed (70% read / 20% write / 10% delete) | 21.4 ms | 19.6 ms | **−8.6%** |
37
- | Insert (random int) | 6.7 ms | 6.4 ms | **−3.5%** |
38
- | Lookup (string keys) | 20.3 ms | 20.8 ms | +2.5% |
39
- | Lookup (sequential int) | 11.7 ms | 12.2 ms | +4.4% |
35
+ |---|---:|---:|---:|
36
+ | Insert (sequential int) | 7.004 ms (±2.4%) | 5.245 ms (±1.2%) | **−25.12%** ⚡ |
37
+ | Insert (string keys) | 17.751 ms (±1.0%) | 11.207 ms (±1.1%) | **−36.86%** |
38
+ | Insert (random int) | 6.148 ms (±1.8%) | 4.845 ms (±0.2%) | **−21.20%** |
39
+ | Lookup (sequential int, 3x) | 12.286 ms (±1.1%) | 11.614 ms (±1.1%) | **−5.47%** |
40
+ | Lookup (string keys, 3x) | 22.558 ms (±0.6%) | 22.650 ms (±0.9%) | +0.41% |
41
+ | Delete + reinsert 25% | 9.517 ms (±0.8%) | 7.392 ms (±1.8%) | **−22.33%** |
42
+ | Mixed (70% read / 20% write / 10% delete) | 25.333 ms (±2.0%) | 21.860 ms (±3.4%) | **−13.71%** ⚡ |
40
43
 
41
44
  ### N = 10,000
42
45
 
43
46
  | Operation | Ruby Hash | SwissHash | Delta |
44
- |---|---|---|---|
45
- | Insert (string keys) | 1.63 ms | 1.10 ms | **−32.8%** ⚡ |
46
- | **Lookup (string keys)** | 1.71 ms | 1.62 ms | **−5.2%** ⚡ |
47
- | Mixed | 1.93 ms | 1.90 ms | −1.6% |
48
- | Delete + reinsert | 0.91 ms | 0.89 ms | −2.0% |
49
- | Insert (sequential int) | 0.66 ms | 0.67 ms | +2.5% |
50
- | Lookup (sequential int) | 1.05 ms | 1.12 ms | +6.0% |
47
+ |---|---:|---:|---:|
48
+ | Insert (sequential int) | 0.606 ms (±2.8%) | 0.518 ms (±1.3%) | **−14.57%** ⚡ |
49
+ | Insert (string keys) | 1.638 ms (±1.9%) | 1.057 ms (±1.2%) | **−35.49%** ⚡ |
50
+ | Insert (random int) | 0.583 ms (±1.5%) | 0.507 ms 1.2%) | **−13.07%** ⚡ |
51
+ | Lookup (sequential int, 3x) | 1.108 ms (±1.3%) | 1.063 ms (±1.4%) | **−4.02%** |
52
+ | Lookup (string keys, 3x) | 1.748 ms (±1.5%) | 1.623 ms (±1.3%) | **−7.12%** |
53
+ | Delete + reinsert 25% | 0.859 ms (±2.2%) | 0.740 ms (±2.2%) | **−13.85%** |
54
+ | Mixed (70% read / 20% write / 10% delete) | 2.123 ms (±1.1%) | 1.928 ms (±1.2%) | **−9.21%** ⚡ |
51
55
 
52
56
  ### N = 1,000
53
57
 
54
- Ruby Hash uses an AR-table (flat array, linear search) for small hashes — SwissHash doesn't have this small-map regime, so for very small integer-keyed workloads Ruby wins. String workloads still favour SwissHash due to wyhash and the lookup fast path.
58
+ Ruby Hash uses an AR-table for small hashes, so very small integer-keyed workloads can still be close. String-heavy workloads remain the strongest SwissHash case.
55
59
 
56
60
  | Operation | Ruby Hash | SwissHash | Delta |
57
- |---|---|---|---|
58
- | Insert (string keys) | 0.183 ms | 0.118 ms | **−35.6%** ⚡ |
59
- | **Lookup (string keys)** | 0.184 ms | 0.155 ms | **−15.5%** ⚡ |
60
- | Insert (sequential int) | 0.063 ms | 0.074 ms | +17.5% |
61
- | Delete + reinsert | 0.094 ms | 0.102 ms | +8.2% |
61
+ |---|---:|---:|---:|
62
+ | Insert (sequential int) | 0.059 ms (±3.2%) | 0.057 ms (±4.6%) | **−4.32%** ⚡ |
63
+ | Insert (string keys) | 0.167 ms (±3.3%) | 0.108 ms (±1.8%) | **−35.17%** ⚡ |
64
+ | Insert (random int) | 0.055 ms (±2.1%) | 0.052 ms (±2.2%) | **−6.03%** |
65
+ | Lookup (sequential int, 3x) | 0.114 ms (±1.8%) | 0.111 ms (±2.3%) | **−2.15%** |
66
+ | Lookup (string keys, 3x) | 0.184 ms (±1.3%) | 0.155 ms (±0.9%) | **−15.68%** ⚡ |
67
+ | Delete + reinsert 25% | 0.086 ms (±2.2%) | 0.080 ms (±2.5%) | **−6.91%** ⚡ |
68
+ | Mixed (70% read / 20% write / 10% delete) | 0.211 ms (±1.4%) | 0.202 ms (±2.0%) | **−4.66%** ⚡ |
62
69
 
63
70
  ### Summary
64
71
 
65
- - **Faster on 5 of 7 operations** at N=100k, some substantially (−36% string insert, −9% mixed workload, −9% delete+reinsert).
66
- - **Strictly faster for string keys** at every size (25–35% faster inserts, break-even to 15% faster lookups).
67
- - **Near parity on lookups** at N=100k (+2.5% on strings, +4.4% on ints) — remaining gap stems from Ruby VM's opcode specialization for `Hash#[]`, not the data structure.
72
+ - SwissHash is faster on **6 of 7 operations** at N=100k; string lookup at that size is within 1% of Ruby Hash.
73
+ - The strongest win is still string-key insertion: **−35% to −37%** across tested sizes.
74
+ - Sequential integer insert at N=1k now beats Ruby Hash (it lost slightly in 0.1.2).
75
+ - ARM64 uses the NEON group-matching path (`stats[:simd] => "NEON"`).
76
+ - Ruby's built-in `Hash` remains excellent, especially for very small maps and cases that benefit from VM-level Hash specialization.
68
77
 
69
78
  ### Memory Usage
70
79
 
71
- For 100,000 integer keys:
72
- - **SwissHash**: 2,176 KB contiguous native memory, 4 GC slots
73
- - **Ruby Hash**: managed via GC slots (not directly measurable)
74
- - **Load factor**: 76.3% actual (max 87.5%)
75
- - **GC pressure**: zero GC runs during insertion
80
+ For 100,000 integer keys in the current benchmark:
81
+
82
+ | Implementation | Reported memory |
83
+ |---|---:|
84
+ | SwissHash | 2,176 KB native + 4 GC slots |
85
+ | Ruby Hash | 3 GC slots; native memory not directly measurable from this benchmark |
86
+
87
+ Additional stats: load factor 76.3%, max load factor 87.5%, SIMD path reported as NEON on the benchmarked Apple Silicon machine.
76
88
 
77
89
  ## Features
78
90
 
79
- - **SIMD-optimized probing**: SSE2 (16-byte groups) on x86_64, NEON (8-byte groups) on ARM64, SWAR fallback elsewhere
80
- - **Memory efficient**: Swiss Table layout with 87.5% max load factor
81
- - **Tombstone compaction**: Automatic cleanup of deleted entries during resize
82
- - **Ruby compatibility**: Supports frozen string keys, all Ruby object types
83
- - **Thread safety**: Prevents reentrant modifications during callbacks
91
+ - **Swiss Table probing**: 7-bit `H2` metadata, group probing, triangular probe sequence, and 87.5% max load factor.
92
+ - **SIMD group matching**: SSE2 on x86_64, NEON on aarch64, SWAR fallback elsewhere.
93
+ - **Fast string-key path**: wyhash for string keys, frozen string key preparation, 7-bit strings of different encodings hash/compare like Ruby Hash, and direct `memcmp` when encodings are compatible.
94
+ - **Low GC pressure**: keys and values are Ruby objects, while control bytes and slots live in contiguous native arrays.
95
+ - **Delete/reinsert friendly**: tombstones are tracked and compacted to avoid pathological slowdown.
96
+ - **Hash-like API**: basic accessors, enumeration, fetch helpers, merge/update/replace, filtering, transforming, slicing, inversion, and conversion helpers.
97
+ - **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.
84
98
 
85
99
  ## API
86
100
 
@@ -89,96 +103,165 @@ hash = SwissHash::Hash.new(capacity = 16)
89
103
 
90
104
  # Basic operations
91
105
  hash[key] = value
92
- hash[key] # get, returns nil if absent
93
- hash.delete(key) # returns old value or nil
106
+ hash.store(key, value)
107
+ hash[key] # returns nil if absent
108
+ hash.fetch(key)
109
+ hash.fetch(key, default)
110
+ hash.fetch(key) { |missing_key| ... }
111
+ hash.delete(key) # returns old value or nil
112
+ hash.clear
113
+ hash.replace(other_hash)
114
+
115
+ # Merge/update
116
+ hash.merge(other_hash)
117
+ hash.merge(other_hash) { |key, old_value, new_value| ... }
118
+ hash.merge!(other_hash)
119
+ hash.update(other_hash)
94
120
 
95
121
  # Enumeration
96
- hash.each { |k, v| ... }
122
+ hash.each { |key, value| ... }
123
+ hash.each_pair { |key, value| ... }
124
+ hash.each_key { |key| ... }
125
+ hash.each_value { |value| ... }
97
126
  hash.keys
98
127
  hash.values
128
+ hash.to_a
99
129
 
100
- # Size and status
101
- hash.size # also: length
130
+ # Query helpers
131
+ hash.size # also: length
102
132
  hash.empty?
103
- hash.key?(key) # also: has_key?, include?
133
+ hash.key?(key) # also: has_key?, include?, member?
134
+ hash.value?(value) # also: has_value?
135
+ hash.key(value) # first key for value, or nil
136
+ hash.assoc(key)
137
+ hash.rassoc(value)
138
+ hash.values_at(*keys)
139
+ hash.fetch_values(*keys)
140
+ hash.dig(key, *path)
141
+ hash.count # Enumerable-compatible
142
+
143
+ # Filtering and transforms
144
+ hash.slice(*keys)
145
+ hash.except(*keys)
146
+ hash.select { |key, value| ... } # also: filter
147
+ hash.select! { |key, value| ... } # also: filter!
148
+ hash.reject { |key, value| ... }
149
+ hash.reject! { |key, value| ... }
150
+ hash.delete_if { |key, value| ... }
151
+ hash.keep_if { |key, value| ... }
152
+ hash.compact
153
+ hash.compact!
154
+ hash.transform_keys { |key| ... }
155
+ hash.transform_keys! { |key| ... }
156
+ hash.transform_values { |value| ... }
157
+ hash.transform_values! { |value| ... }
158
+ hash.invert
159
+ hash.shift
160
+ hash.flatten(level = 1)
161
+
162
+ # Conversion
163
+ hash.to_h # returns a Ruby Hash
164
+ hash.to_sh # returns a shallow SwissHash copy
165
+
166
+ # Maintenance / debugging
167
+ hash.compact_storage! # drop tombstones without changing values
168
+ hash.stats # => { capacity:, size:, num_groups:, load_factor:,
169
+ # memory_bytes:, growth_left:, tombstones:,
170
+ # simd:, layout: }
171
+ ```
104
172
 
105
- # Maintenance
106
- hash.clear
107
- hash.compact! # drop tombstones without reallocating
173
+ ### Compatibility notes
108
174
 
109
- # Debugging
110
- hash.stats # => { capacity:, size:, num_groups:, load_factor:,
111
- # memory_bytes:, growth_left:, tombstones:, simd: }
112
- ```
175
+ 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.
176
+
177
+ Not currently supported:
178
+
179
+ - default values and default blocks from `Hash.new(default)` / `Hash.new { ... }`
180
+ - `compare_by_identity`
181
+ - full insertion-order guarantees
182
+ - every rarely used method from Ruby's full `Hash` API
113
183
 
114
184
  ## Usage Recommendations
115
185
 
116
186
  Use SwissHash when:
117
- - Your hash keys are **strings** — inserts are 25–35% faster, lookups are on par or faster
118
- - Your hash holds **10,000+ entries** with any mix of reads, writes, and deletes
119
- - You do **heavy delete/reinsert churn** — tombstone compaction handles it without pathological slowdown
120
- - You need **predictable native memory** instead of scattered GC allocations
187
+
188
+ - keys are mostly **strings** and insert speed matters;
189
+ - the map commonly holds **10,000+ entries**;
190
+ - workloads include deletes and reinserts;
191
+ - predictable native memory layout and lower Ruby-object churn are useful.
121
192
 
122
193
  Stick with Ruby's built-in `Hash` when:
123
- - Your hash is small (≤ a few hundred entries) and mostly lookup-heavy with integer keys — Ruby's AR-table wins for small integer-keyed workloads
124
- - You depend on Hash-specific semantics: default blocks, `compare_by_identity`, full insertion-order guarantees, or the complete `Hash` API
194
+
195
+ - the hash is small and mostly lookup-heavy with integer keys;
196
+ - you depend on exact Ruby Hash semantics such as defaults, insertion order, `compare_by_identity`, or the complete standard API;
197
+ - the code path benefits from VM-level `Hash#[]` specialization more than from the underlying table layout.
125
198
 
126
199
  ## Architecture
127
200
 
128
201
  ### Swiss Table core
129
- - **Open addressing** with 7-bit `H2` metadata byte per slot; SIMD rejects non-matching slots in parallel
130
- - **Group size 16 on SSE2** (full `_mm_movemask_epi8` width, matching Abseil / hashbrown); **group size 8 on NEON** and portable SWAR fallback — matches hashbrown's deliberate ARM choice (NEON's multi-cycle movemask latency makes 16-wide groups lose to 8-wide SWAR)
131
- - **Triangular probing** `i(i+1)/2` guarantees full coverage on power-of-2 capacities
132
- - **Max load factor 87.5%** (7/8)
202
+
203
+ - **Open addressing** with 7-bit `H2` metadata byte per slot; group matching rejects non-matching slots in batches.
204
+ - **Group size 16 on SSE2 (x86_64) and NEON (aarch64)**, **group size 8 on portable SWAR**. The active path is printed by `stats[:simd]` / the benchmark memory section (`NEON` on Apple Silicon).
205
+ - **Triangular probing** `i(i+1)/2` — over power-of-two group counts.
206
+ - **Max load factor 87.5%** (7/8).
133
207
 
134
208
  ### Ruby-specific adaptations
135
- - **wyhash** for string keys — faster than Ruby's SipHash on short strings, which dominate typical workloads
136
- - **Fibonacci multiplicative hash** for Fixnum and Symbol keys — their low bits are already well-distributed, so avalanche mixers would be wasted work
137
- - **ASCII-7bit fast-path** in key equality: frozen string keys have their coderange pre-computed on insert, so subsequent lookup comparisons skip `rb_enc_compatible` entirely and go straight to `memcmp`
138
- - **Encoding-index equality check** as the first fast path in key comparison — avoids `rb_enc_compatible` on the common case of matching encodings
139
- - **Inline `RTYPEDDATA_DATA`** on hot methods (`[]`, `[]=`, `delete`, `key?`) skips the type-check overhead of `TypedData_Get_Struct` on every operation
140
- - **Prefetch `slots[off]`** right after the control-byte load so DRAM fetch overlaps with SIMD match extraction
209
+
210
+ - **wyhash** for string keys.
211
+ - **Fibonacci multiplicative hash** for Fixnum and Symbol keys.
212
+ - **Frozen string key preparation** to avoid later key mutation surprises.
213
+ - **ASCII-7bit and encoding-index equality fast paths** before falling back to Ruby-compatible string comparison.
214
+ - **Inline `RTYPEDDATA_DATA`** on hot methods (`[]`, `[]=`, `delete`, `key?`) to avoid repeated typed-data checks.
215
+ - **Prefetch of slot groups** after control-byte load so data fetch overlaps with match extraction.
141
216
 
142
217
  ### Memory layout
143
- - Separate control-byte array and slot array (hashbrown-style) — the tight control array scans well through L1/L2
144
- - No zero-initialization of slot memory (`malloc` instead of `calloc`) — slots are only ever read after their control byte confirms they're live
218
+
219
+ - One native allocation for control bytes followed by slots (16-byte aligned). `stats[:memory_bytes]` still counts `capacity * (1 + sizeof(Slot))`.
220
+ - Native arrays are allocated outside Ruby's object heap; keys and values are still marked for GC.
221
+ - Slot memory is not zero-initialized on allocation; slots are read only after their control byte marks them live.
145
222
 
146
223
  ## Build
147
224
 
225
+ ```bash
226
+ bundle install
227
+ bundle exec rake compile
148
228
  ```
149
- rake compile
229
+
230
+ ## Test
231
+
232
+ ```bash
233
+ bundle exec ruby test/hash_api_test.rb
234
+ bundle exec ruby test/string_key_mutation_test.rb
235
+ bundle exec ruby test/safety_and_encoding_test.rb
150
236
  ```
151
237
 
152
238
  ## Benchmarking
153
239
 
154
- The included `benchmark.rb` produces statistically honest results:
155
-
156
240
  ```bash
157
241
  bundle exec ruby benchmark.rb
158
242
  ```
159
243
 
160
- Key features that make it trustworthy:
161
- - **Interleaved Ruby/SwissHash measurements** per iteration with alternating start order — thermal throttling and fluctuating background load hit both sides equally
162
- - **21 iterations with IQR-filtered mean** (trims top and bottom 25%) — more robust than median on noisy laptop hardware
163
- - **5 warmup runs** to settle JIT, caches, and branch predictor
164
- - **Per-side coefficient of variation** (`±X.X%`) displayed so you can distinguish a real 5% delta from 5% noise
165
- - **Correctness smoke test** runs before measurement
244
+ The benchmark includes a smoke test before timing and prints the active SIMD/SWAR path in the memory section.
166
245
 
167
246
  ### Profiling
168
247
 
169
248
  For profiling on macOS:
170
249
 
171
250
  ```bash
172
- bundle exec ruby simp.rb # runs infinite lookup loop, prints PID
251
+ bundle exec ruby simp.rb # runs an infinite lookup loop and prints PID
173
252
  sample <PID> 60 -f /tmp/swiss.sample
174
253
  filtercalltree /tmp/swiss.sample | head -100
175
254
  ```
176
255
 
256
+ ## Changelog
257
+
258
+ See [CHANGELOG.md](CHANGELOG.md).
259
+
177
260
  ## Design References
178
261
 
179
262
  - Matt Kulukundis, ["Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step"](https://www.youtube.com/watch?v=ncHmEUmJZf4) — CppCon 2017
180
263
  - [Abseil: SwissTables design](https://abseil.io/about/design/swisstables)
181
- - [rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) — reference for SSE2/NEON/SWAR strategy choices
264
+ - [rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) — reference for SSE2/portable group strategy choices
182
265
  - [Go 1.24 maps](https://go.dev/blog/swisstable) — probing and resize design trade-offs
183
266
  - Aria Beingessner, ["Swisstable, a Quick and Dirty Description"](https://faultlore.com/blah/hashbrown-tldr/) — implementer's notes
184
267