swiss_hash 0.1.1 → 0.1.2

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: bd83d549a02b9134baf31b4fa97b7eca44f370653cb1a3af0076f9d1196c1e15
4
+ data.tar.gz: 11a81f20822f58be2f18936e9a7e5bda112759868d91a8d7e2dcc1332bce598a
5
5
  SHA512:
6
- metadata.gz: 2587c907c6d77be5049d0b08edfa8f8c6c03b0503eb380e4577cf64dc722370e9fb1841778f55d86869d8dcc6d61517cfe8a199521eb1e5926783fd3d1e8deda
7
- data.tar.gz: 9222dcf296f098faa0774e9b54b67aab36e40efdf5576049eac2d79880d1b61f55f27eb39239e71996c41a4d239d537e9cf42e2f1154c9c9ea2026901f77c302
6
+ metadata.gz: 79009afb0d1ef895a975ac2ad10c612845f5e81e473e4476e9cb7a5732f482241a5ebb73427633aeecffaf3c516406e8f88c99c69d02da5db19c8ca1aa8db53d
7
+ data.tar.gz: 0736cadfdf1e688e4c47f29c120edc8c9eddc1602b9ab933fd740fd954db7af076ae1eea4f333028d2be8edf9e066ab7797a4dc1ec6faf91b7fd37a7f10f58bc
data/CHANGELOG.md ADDED
@@ -0,0 +1,73 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 - Unreleased
4
+
5
+ Documentation and API polish for the first public release candidate.
6
+
7
+ ### Added / changed
8
+
9
+ - Expanded the Hash-like API surface:
10
+ - `fetch`, `values_at`, `fetch_values`
11
+ - `merge`, `merge!`, `update`, `replace`
12
+ - `to_h`, `to_sh`, `to_a`
13
+ - `slice`, `except`, `invert`, `assoc`, `rassoc`, `shift`
14
+ - `delete_if`, `keep_if`, `select`, `select!`, `filter`, `filter!`, `reject`, `reject!`
15
+ - `compact`, `compact!`, `transform_keys`, `transform_keys!`, `transform_values`, `transform_values!`
16
+ - `value?`, `has_value?`, `key`, `dig`, `count`, `flatten`, `==`, `eql?`, `hash`, `inspect`
17
+ - Moved the important Hash-like operations into the C extension instead of doing the expensive parts in Ruby.
18
+ - Kept `to_h` as the Ruby-compatible conversion to a real `Hash`; added `to_sh` as the explicit shallow SwissHash copy.
19
+ - Updated the README with the latest benchmark output from Ruby 3.4.3 / arm64-darwin24.
20
+ - 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.
21
+
22
+ ### Benchmark update
23
+
24
+ Struck-through values are the previous README numbers. The value after the arrow is the current benchmark result.
25
+
26
+ #### N = 100,000
27
+
28
+ | Operation | Ruby Hash | SwissHash | Delta |
29
+ |---|---:|---:|---:|
30
+ | Insert (string keys) | ~~17.6 ms~~ → 16.485 ms | ~~11.3 ms~~ → 10.386 ms | ~~−35.8%~~ → **−37.00%** |
31
+ | Delete + reinsert 25% | ~~9.8 ms~~ → 8.965 ms | ~~8.9 ms~~ → 7.164 ms | ~~−9.0%~~ → **−20.09%** |
32
+ | Insert (sequential int) | ~~7.0 ms~~ → 6.324 ms | ~~6.4 ms~~ → 5.023 ms | ~~−8.7%~~ → **−20.57%** |
33
+ | Mixed (70/20/10 R/W/D) | ~~21.4 ms~~ → 21.784 ms | ~~19.6 ms~~ → 18.990 ms | ~~−8.6%~~ → **−12.83%** |
34
+ | Insert (random int) | ~~6.7 ms~~ → 5.963 ms | ~~6.4 ms~~ → 5.010 ms | ~~−3.5%~~ → **−15.98%** |
35
+ | Lookup (string keys, 3x) | ~~20.3 ms~~ → 20.561 ms | ~~20.8 ms~~ → 21.535 ms | ~~+2.5%~~ → +4.74% |
36
+ | Lookup (sequential int, 3x) | ~~11.7 ms~~ → 13.281 ms | ~~12.2 ms~~ → 11.116 ms | ~~+4.4%~~ → **−16.31%** |
37
+
38
+ #### N = 10,000
39
+
40
+ | Operation | Ruby Hash | SwissHash | Delta |
41
+ |---|---:|---:|---:|
42
+ | Insert (string keys) | ~~1.63 ms~~ → 1.522 ms | ~~1.10 ms~~ → 0.992 ms | ~~−32.8%~~ → **−34.80%** |
43
+ | Lookup (string keys, 3x) | ~~1.71 ms~~ → 1.710 ms | ~~1.62 ms~~ → 1.563 ms | ~~−5.2%~~ → **−8.58%** |
44
+ | Mixed (70/20/10 R/W/D) | ~~1.93 ms~~ → 1.988 ms | ~~1.90 ms~~ → 1.869 ms | ~~−1.6%~~ → **−5.98%** |
45
+ | Delete + reinsert 25% | ~~0.91 ms~~ → 0.814 ms | ~~0.89 ms~~ → 0.714 ms | ~~−2.0%~~ → **−12.29%** |
46
+ | Insert (sequential int) | ~~0.66 ms~~ → 0.578 ms | ~~0.67 ms~~ → 0.510 ms | ~~+2.5%~~ → **−11.82%** |
47
+ | Lookup (sequential int, 3x) | ~~1.05 ms~~ → 1.071 ms | ~~1.12 ms~~ → 1.065 ms | ~~+6.0%~~ → −0.55% |
48
+ | Insert (random int) | previously not listed → 0.554 ms | previously not listed → 0.501 ms | **−9.53%** |
49
+
50
+ #### N = 1,000
51
+
52
+ | Operation | Ruby Hash | SwissHash | Delta |
53
+ |---|---:|---:|---:|
54
+ | Insert (string keys) | ~~0.183 ms~~ → 0.156 ms | ~~0.118 ms~~ → 0.102 ms | ~~−35.6%~~ → **−34.78%** |
55
+ | Lookup (string keys, 3x) | ~~0.184 ms~~ → 0.181 ms | ~~0.155 ms~~ → 0.150 ms | ~~−15.5%~~ → **−17.10%** |
56
+ | Insert (sequential int) | ~~0.063 ms~~ → 0.056 ms | ~~0.074 ms~~ → 0.057 ms | ~~+17.5%~~ → +1.88% |
57
+ | Delete + reinsert 25% | ~~0.094 ms~~ → 0.082 ms | ~~0.102 ms~~ → 0.079 ms | ~~+8.2%~~ → −4.12% |
58
+ | Insert (random int) | previously not listed → 0.054 ms | previously not listed → 0.051 ms | −6.54% |
59
+ | Lookup (sequential int, 3x) | previously not listed → 0.111 ms | previously not listed → 0.111 ms | +0.29% |
60
+ | Mixed (70/20/10 R/W/D) | previously not listed → 0.202 ms | previously not listed → 0.197 ms | −2.04% |
61
+
62
+ ### Memory update
63
+
64
+ | Metric | Previous README | Current benchmark |
65
+ |---|---:|---:|
66
+ | SwissHash native memory, N=100,000 | ~~2,176 KB + 4 GC slots~~ | 2,176 KB native + 4 GC slots |
67
+ | Ruby Hash | ~~managed via GC slots / not directly measurable~~ | 3 GC slots; native memory not directly measurable |
68
+ | Load factor | ~~76.3%~~ | 76.3% |
69
+ | SIMD path | not shown in table | SWAR |
70
+
71
+ ## 0.1.1 and earlier
72
+
73
+ 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,85 @@ 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: 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.
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) | 6.324 ms (±0.5%) | 5.023 ms (±0.6%) | **−20.57%** ⚡ |
37
+ | Insert (string keys) | 16.485 ms (±4.6%) | 10.386 ms (±3.0%) | **−37.00%** |
38
+ | Insert (random int) | 5.963 ms (±1.2%) | 5.010 ms (±1.0%) | **−15.98%** |
39
+ | Lookup (sequential int, 3x) | 13.281 ms (±0.1%) | 11.116 ms (±0.1%) | **−16.31%** |
40
+ | Lookup (string keys, 3x) | 20.561 ms (±3.8%) | 21.535 ms (±4.9%) | +4.74% |
41
+ | Delete + reinsert 25% | 8.965 ms (±0.7%) | 7.164 ms (±0.8%) | **−20.09%** |
42
+ | Mixed (70% read / 20% write / 10% delete) | 21.784 ms (±0.2%) | 18.990 ms (±0.3%) | **−12.83%** |
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.578 ms (±1.1%) | 0.510 ms (±1.1%) | **−11.82%** ⚡ |
49
+ | Insert (string keys) | 1.522 ms (±3.5%) | 0.992 ms (±1.6%) | **−34.80%** ⚡ |
50
+ | Insert (random int) | 0.554 ms (±2.0%) | 0.501 ms (±2.5%) | **−9.53%** |
51
+ | Lookup (sequential int, 3x) | 1.071 ms (±0.3%) | 1.065 ms (±0.1%) | −0.55% |
52
+ | Lookup (string keys, 3x) | 1.710 ms (±1.3%) | 1.563 ms (±1.4%) | **−8.58%** |
53
+ | Delete + reinsert 25% | 0.814 ms (±1.6%) | 0.714 ms (±1.2%) | **−12.29%** |
54
+ | Mixed (70% read / 20% write / 10% delete) | 1.988 ms (±0.4%) | 1.869 ms (±0.5%) | **−5.98%** ⚡ |
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 favour the built-in implementation. String-heavy workloads continue to be 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.056 ms (±0.7%) | 0.057 ms (±0.9%) | +1.88% |
63
+ | Insert (string keys) | 0.156 ms (±2.5%) | 0.102 ms (±0.9%) | **−34.78%** ⚡ |
64
+ | Insert (random int) | 0.054 ms (±3.5%) | 0.051 ms (±3.4%) | −6.54% |
65
+ | Lookup (sequential int, 3x) | 0.111 ms (±0.4%) | 0.111 ms (±0.2%) | +0.29% |
66
+ | Lookup (string keys, 3x) | 0.181 ms (±0.3%) | 0.150 ms (±0.4%) | **−17.10%** ⚡ |
67
+ | Delete + reinsert 25% | 0.082 ms (±0.9%) | 0.079 ms (±0.5%) | −4.12% |
68
+ | Mixed (70% read / 20% write / 10% delete) | 0.202 ms (±0.3%) | 0.197 ms (±0.2%) | −2.04% |
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 in the current benchmark run.
73
+ - The strongest win is still string-key insertion: **−34% to −37%** across tested sizes.
74
+ - 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.
75
+ - 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.
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 SWAR on the benchmark 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
+ - **Fast string-key path**: wyhash for string keys, frozen string key preparation, ASCII-7bit equality shortcut, and direct `memcmp` when encodings are compatible.
93
+ - **Low GC pressure**: keys and values are Ruby objects, while control bytes and slots live in contiguous native arrays.
94
+ - **Delete/reinsert friendly**: tombstones are tracked and compacted to avoid pathological slowdown.
95
+ - **Hash-like API**: basic accessors, enumeration, fetch helpers, merge/update/replace, filtering, transforming, slicing, inversion, and conversion helpers.
96
+ - **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
97
 
85
98
  ## API
86
99
 
@@ -89,96 +102,164 @@ hash = SwissHash::Hash.new(capacity = 16)
89
102
 
90
103
  # Basic operations
91
104
  hash[key] = value
92
- hash[key] # get, returns nil if absent
93
- hash.delete(key) # returns old value or nil
105
+ hash.store(key, value)
106
+ hash[key] # returns nil if absent
107
+ hash.fetch(key)
108
+ hash.fetch(key, default)
109
+ hash.fetch(key) { |missing_key| ... }
110
+ hash.delete(key) # returns old value or nil
111
+ hash.clear
112
+ hash.replace(other_hash)
113
+
114
+ # Merge/update
115
+ hash.merge(other_hash)
116
+ hash.merge(other_hash) { |key, old_value, new_value| ... }
117
+ hash.merge!(other_hash)
118
+ hash.update(other_hash)
94
119
 
95
120
  # Enumeration
96
- hash.each { |k, v| ... }
121
+ hash.each { |key, value| ... }
122
+ hash.each_pair { |key, value| ... }
123
+ hash.each_key { |key| ... }
124
+ hash.each_value { |value| ... }
97
125
  hash.keys
98
126
  hash.values
127
+ hash.to_a
99
128
 
100
- # Size and status
101
- hash.size # also: length
129
+ # Query helpers
130
+ hash.size # also: length
102
131
  hash.empty?
103
- hash.key?(key) # also: has_key?, include?
132
+ hash.key?(key) # also: has_key?, include?, member?
133
+ hash.value?(value) # also: has_value?
134
+ hash.key(value) # first key for value, or nil
135
+ hash.assoc(key)
136
+ hash.rassoc(value)
137
+ hash.values_at(*keys)
138
+ hash.fetch_values(*keys)
139
+ hash.dig(key, *path)
140
+ hash.count # Enumerable-compatible
141
+
142
+ # Filtering and transforms
143
+ hash.slice(*keys)
144
+ hash.except(*keys)
145
+ hash.select { |key, value| ... } # also: filter
146
+ hash.select! { |key, value| ... } # also: filter!
147
+ hash.reject { |key, value| ... }
148
+ hash.reject! { |key, value| ... }
149
+ hash.delete_if { |key, value| ... }
150
+ hash.keep_if { |key, value| ... }
151
+ hash.compact
152
+ hash.compact!
153
+ hash.transform_keys { |key| ... }
154
+ hash.transform_keys! { |key| ... }
155
+ hash.transform_values { |value| ... }
156
+ hash.transform_values! { |value| ... }
157
+ hash.invert
158
+ hash.shift
159
+ hash.flatten(level = 1)
160
+
161
+ # Conversion
162
+ hash.to_h # returns a Ruby Hash
163
+ hash.to_sh # returns a shallow SwissHash copy
164
+
165
+ # Maintenance / debugging
166
+ hash.compact_storage! # drop tombstones without changing values
167
+ hash.stats # => { capacity:, size:, num_groups:, load_factor:,
168
+ # memory_bytes:, growth_left:, tombstones:,
169
+ # simd:, layout: }
170
+ ```
104
171
 
105
- # Maintenance
106
- hash.clear
107
- hash.compact! # drop tombstones without reallocating
172
+ ### Compatibility notes
108
173
 
109
- # Debugging
110
- hash.stats # => { capacity:, size:, num_groups:, load_factor:,
111
- # memory_bytes:, growth_left:, tombstones:, simd: }
112
- ```
174
+ 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.
175
+
176
+ Not currently supported:
177
+
178
+ - default values and default blocks from `Hash.new(default)` / `Hash.new { ... }`
179
+ - `compare_by_identity`
180
+ - full insertion-order guarantees
181
+ - every rarely used method from Ruby's full `Hash` API
113
182
 
114
183
  ## Usage Recommendations
115
184
 
116
185
  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
186
+
187
+ - keys are mostly **strings** and insert speed matters;
188
+ - the map commonly holds **10,000+ entries**;
189
+ - workloads include deletes and reinserts;
190
+ - predictable native memory layout and lower Ruby-object churn are useful.
121
191
 
122
192
  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
193
+
194
+ - the hash is small and mostly lookup-heavy with integer keys;
195
+ - you depend on exact Ruby Hash semantics such as defaults, insertion order, `compare_by_identity`, or the complete standard API;
196
+ - the code path benefits from VM-level `Hash#[]` specialization more than from the underlying table layout.
125
197
 
126
198
  ## Architecture
127
199
 
128
200
  ### 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)
201
+
202
+ - **Open addressing** with 7-bit `H2` metadata byte per slot; group matching rejects non-matching slots in batches.
203
+ - **Group size 16 on SSE2** and **group size 8 on portable SWAR**. On the benchmarked Apple Silicon machine the active path is SWAR.
204
+ - **Triangular probing** `i(i+1)/2` — over power-of-two group counts.
205
+ - **Max load factor 87.5%** (7/8).
133
206
 
134
207
  ### 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
208
+
209
+ - **wyhash** for string keys.
210
+ - **Fibonacci multiplicative hash** for Fixnum and Symbol keys.
211
+ - **Frozen string key preparation** to avoid later key mutation surprises.
212
+ - **ASCII-7bit and encoding-index equality fast paths** before falling back to Ruby-compatible string comparison.
213
+ - **Inline `RTYPEDDATA_DATA`** on hot methods (`[]`, `[]=`, `delete`, `key?`) to avoid repeated typed-data checks.
214
+ - **Prefetch of slot groups** after control-byte load so data fetch overlaps with match extraction.
141
215
 
142
216
  ### 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
217
+
218
+ - Separate control-byte array and slot array.
219
+ - Native arrays are allocated outside Ruby's object heap; keys and values are still marked for GC.
220
+ - Slot memory is not zero-initialized on allocation; slots are read only after their control byte marks them live.
145
221
 
146
222
  ## Build
147
223
 
224
+ ```bash
225
+ bundle install
226
+ bundle exec rake compile
148
227
  ```
149
- rake compile
228
+
229
+ ## Test
230
+
231
+ ```bash
232
+ bundle exec ruby test/hash_api_test.rb
233
+ bundle exec ruby test/string_key_mutation_test.rb
150
234
  ```
151
235
 
152
236
  ## Benchmarking
153
237
 
154
- The included `benchmark.rb` produces statistically honest results:
155
-
156
238
  ```bash
157
239
  bundle exec ruby benchmark.rb
158
240
  ```
159
241
 
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
242
+ The benchmark includes a smoke test before timing and prints the active SIMD/SWAR path in the memory section.
166
243
 
167
244
  ### Profiling
168
245
 
169
246
  For profiling on macOS:
170
247
 
171
248
  ```bash
172
- bundle exec ruby simp.rb # runs infinite lookup loop, prints PID
249
+ bundle exec ruby simp.rb # runs an infinite lookup loop and prints PID
173
250
  sample <PID> 60 -f /tmp/swiss.sample
174
251
  filtercalltree /tmp/swiss.sample | head -100
175
252
  ```
176
253
 
254
+ ## Changelog
255
+
256
+ See [CHANGELOG.md](CHANGELOG.md).
257
+
177
258
  ## Design References
178
259
 
179
260
  - Matt Kulukundis, ["Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step"](https://www.youtube.com/watch?v=ncHmEUmJZf4) — CppCon 2017
180
261
  - [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
262
+ - [rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) — reference for SSE2/portable group strategy choices
182
263
  - [Go 1.24 maps](https://go.dev/blog/swisstable) — probing and resize design trade-offs
183
264
  - Aria Beingessner, ["Swisstable, a Quick and Dirty Description"](https://faultlore.com/blah/hashbrown-tldr/) — implementer's notes
184
265