swiss_hash 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +73 -0
- data/LICENSE.txt +1 -1
- data/README.md +199 -73
- data/ext/swiss_hash/swiss_hash.c +709 -114
- data/lib/swiss_hash/version.rb +1 -1
- data/lib/swiss_hash.rb +37 -9
- metadata +4 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd83d549a02b9134baf31b4fa97b7eca44f370653cb1a3af0076f9d1196c1e15
|
|
4
|
+
data.tar.gz: 11a81f20822f58be2f18936e9a7e5bda112759868d91a8d7e2dcc1332bce598a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# SwissHash
|
|
2
2
|
|
|
3
|
-
Swiss Table hash map implementation as a Ruby C extension.
|
|
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,42 +15,85 @@ require "swiss_hash"
|
|
|
15
15
|
|
|
16
16
|
h = SwissHash::Hash.new
|
|
17
17
|
h["key"] = "value"
|
|
18
|
-
h["key"]
|
|
19
|
-
h.
|
|
20
|
-
h.
|
|
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.
|
|
28
|
+
Benchmarks below were produced by `benchmark.rb` on Ruby 3.4.3 / arm64-darwin24.
|
|
29
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
### N = 100,000
|
|
33
|
+
|
|
34
|
+
| Operation | Ruby Hash | SwissHash | Delta |
|
|
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%** ⚡ |
|
|
43
|
+
|
|
44
|
+
### N = 10,000
|
|
45
|
+
|
|
46
|
+
| Operation | Ruby Hash | SwissHash | Delta |
|
|
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%** ⚡ |
|
|
55
|
+
|
|
56
|
+
### N = 1,000
|
|
26
57
|
|
|
27
|
-
|
|
28
|
-
- **String keys**: SwissHash is **18.9-28.6% faster** across all dataset sizes
|
|
29
|
-
- **Sequential integers**: 24.6% slower (1k), 3.7% slower (10k), **11.5% faster** (100k)
|
|
30
|
-
- **Random integers**: 11.6% slower (1k), 4.7% slower (10k), **6.3% faster** (100k)
|
|
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.
|
|
31
59
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
| Operation | Ruby Hash | SwissHash | Delta |
|
|
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% |
|
|
35
69
|
|
|
36
|
-
###
|
|
37
|
-
|
|
38
|
-
-
|
|
70
|
+
### Summary
|
|
71
|
+
|
|
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.
|
|
39
77
|
|
|
40
78
|
### Memory Usage
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
|
|
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.
|
|
46
88
|
|
|
47
89
|
## Features
|
|
48
90
|
|
|
49
|
-
- **
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
52
|
-
- **
|
|
53
|
-
- **
|
|
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.
|
|
54
97
|
|
|
55
98
|
## API
|
|
56
99
|
|
|
@@ -59,83 +102,166 @@ hash = SwissHash::Hash.new(capacity = 16)
|
|
|
59
102
|
|
|
60
103
|
# Basic operations
|
|
61
104
|
hash[key] = value
|
|
62
|
-
hash
|
|
63
|
-
hash
|
|
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)
|
|
64
119
|
|
|
65
120
|
# Enumeration
|
|
66
|
-
hash.each { |
|
|
121
|
+
hash.each { |key, value| ... }
|
|
122
|
+
hash.each_pair { |key, value| ... }
|
|
123
|
+
hash.each_key { |key| ... }
|
|
124
|
+
hash.each_value { |value| ... }
|
|
67
125
|
hash.keys
|
|
68
126
|
hash.values
|
|
127
|
+
hash.to_a
|
|
69
128
|
|
|
70
|
-
#
|
|
71
|
-
hash.size
|
|
129
|
+
# Query helpers
|
|
130
|
+
hash.size # also: length
|
|
72
131
|
hash.empty?
|
|
73
|
-
hash.key?(key)
|
|
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
|
+
```
|
|
74
171
|
|
|
75
|
-
|
|
76
|
-
hash.clear
|
|
77
|
-
hash.compact! # remove tombstones
|
|
172
|
+
### Compatibility notes
|
|
78
173
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
82
182
|
|
|
83
183
|
## Usage Recommendations
|
|
84
184
|
|
|
85
|
-
SwissHash
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
185
|
+
Use SwissHash when:
|
|
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.
|
|
191
|
+
|
|
192
|
+
Stick with Ruby's built-in `Hash` when:
|
|
90
193
|
|
|
91
|
-
|
|
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.
|
|
92
197
|
|
|
93
|
-
##
|
|
198
|
+
## Architecture
|
|
94
199
|
|
|
95
|
-
|
|
200
|
+
### Swiss Table core
|
|
96
201
|
|
|
97
|
-
|
|
98
|
-
- **
|
|
99
|
-
- **
|
|
100
|
-
- **
|
|
101
|
-
- **Memory locality**: Ruby Hash uses a simpler layout optimized for CPU cache performance
|
|
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).
|
|
102
206
|
|
|
103
|
-
###
|
|
104
|
-
- **C Extension boundaries**: Each lookup requires `TypedData_Get_Struct()` call and Ruby-C interface overhead
|
|
105
|
-
- **Complex hash computation**: SwissHash uses sophisticated hashing (wyhash for strings, custom mixers) vs Ruby's simpler approach
|
|
106
|
-
- **Two-level lookup**: Swiss Table's H1/H2 split requires additional bit manipulation and SIMD operations
|
|
107
|
-
- **SIMD overhead**: NEON/SSE2 operations have setup costs that don't pay off for small group scans
|
|
108
|
-
- **Additional equality checks**: `keys_equal()` function adds extra indirection compared to VM's direct comparison
|
|
207
|
+
### Ruby-specific adaptations
|
|
109
208
|
|
|
110
|
-
|
|
111
|
-
- **
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
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.
|
|
114
215
|
|
|
115
|
-
|
|
216
|
+
### Memory layout
|
|
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.
|
|
116
221
|
|
|
117
222
|
## Build
|
|
118
223
|
|
|
224
|
+
```bash
|
|
225
|
+
bundle install
|
|
226
|
+
bundle exec rake compile
|
|
119
227
|
```
|
|
120
|
-
|
|
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
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Benchmarking
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
bundle exec ruby benchmark.rb
|
|
121
240
|
```
|
|
122
241
|
|
|
123
|
-
|
|
242
|
+
The benchmark includes a smoke test before timing and prints the active SIMD/SWAR path in the memory section.
|
|
243
|
+
|
|
244
|
+
### Profiling
|
|
124
245
|
|
|
125
|
-
|
|
246
|
+
For profiling on macOS:
|
|
126
247
|
|
|
127
248
|
```bash
|
|
128
|
-
ruby
|
|
249
|
+
bundle exec ruby simp.rb # runs an infinite lookup loop and prints PID
|
|
250
|
+
sample <PID> 60 -f /tmp/swiss.sample
|
|
251
|
+
filtercalltree /tmp/swiss.sample | head -100
|
|
129
252
|
```
|
|
130
253
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
- Memory usage and GC pressure analysis
|
|
254
|
+
## Changelog
|
|
255
|
+
|
|
256
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
257
|
+
|
|
258
|
+
## Design References
|
|
137
259
|
|
|
138
|
-
|
|
260
|
+
- Matt Kulukundis, ["Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step"](https://www.youtube.com/watch?v=ncHmEUmJZf4) — CppCon 2017
|
|
261
|
+
- [Abseil: SwissTables design](https://abseil.io/about/design/swisstables)
|
|
262
|
+
- [rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) — reference for SSE2/portable group strategy choices
|
|
263
|
+
- [Go 1.24 maps](https://go.dev/blog/swisstable) — probing and resize design trade-offs
|
|
264
|
+
- Aria Beingessner, ["Swisstable, a Quick and Dirty Description"](https://faultlore.com/blah/hashbrown-tldr/) — implementer's notes
|
|
139
265
|
|
|
140
266
|
## License
|
|
141
267
|
|