swiss_hash 0.1.3 → 0.1.4

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: b9d4f872535c0a7e7558f6c7950a2fb113263ab9b75cd2397bff72ee4185c295
4
- data.tar.gz: ad110fb4e54d662181d3e49546eec833a953be7b87debae477837ad8e50408a5
3
+ metadata.gz: 0c2f1e2b2c9e7a8ee0ba2b4ab30f7088eeeefdd1e56abc6fb92758bd5a667a64
4
+ data.tar.gz: 92904e400f7f16702a48c2966afc8c41b4e05aacd769fbd3ff9abd54d8cd5b53
5
5
  SHA512:
6
- metadata.gz: 85cecddcb36ff970d62bd053bb78c7b42f0f15290276f64326ecc053236ca6ff473ed016933dd848ce9d9f4e5b63efdebb014140a84b22436e9bb9614567b4cf
7
- data.tar.gz: 2cf36fed40554508684932d155bad203b4da18d9b808d2a8317b5f0e46b5a2e3dee9ebe3ab963ecdbd75406e99075d816d2dcf9b1277b562c6b421a845b1d8ec
6
+ metadata.gz: e4a90125a2c8d4792299a7899331f14a0f285cb15fe497a5c53258641846f5811d8f07127cee8a3c0f698f35011a6a45190deeaa9149642fa36856ca04fc12fd
7
+ data.tar.gz: d93013782fc2df669c537d4f0907c2c398781ddf077afd79dc0ddce455f18476ba2e0dc798dd09637c21cb866eb4ceed06c63669b8e625c5f26c5ae48633ab55
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-10-28
4
+
5
+ - Prevented reentrant Fixnum insertion and `clear`/`initialize` from modifying a table during hash callbacks.
6
+ - Made reinitialization preserve the existing table if capacity validation or allocation fails; rejected overflowing capacities.
7
+ - Made `replace` with itself a no-op and kept contents intact when the replacement cannot convert to a Hash.
8
+ - Updated benchmark memory reporting to compare container storage and directly retained keys/values for Ruby Hash and SwissHash.
9
+ - Refreshed README timings and memory results for Ruby 3.4.3 / arm64-darwin24.
10
+
3
11
  ## 0.1.3 - 2026-08-18
4
12
 
5
13
  - NEON group matching on ARM64 (SSE2 on x86_64, SWAR elsewhere)
data/README.md CHANGED
@@ -27,64 +27,63 @@ h.stats # => { capacity: 16, size: 0, ... }
27
27
 
28
28
  Benchmarks below were produced by `benchmark.rb` on Ruby 3.4.3 / arm64-darwin24.
29
29
 
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.
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
31
 
32
32
  ### N = 100,000
33
33
 
34
34
  | Operation | Ruby Hash | SwissHash | Delta |
35
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%** ⚡ |
36
+ | Insert (sequential int) | 6.412 ms (±0.5%) | 4.973 ms (±0.7%) | **−22.44%** ⚡ |
37
+ | Insert (string keys) | 16.130 ms (±0.2%) | 10.470 ms (±0.2%) | **−35.09%** ⚡ |
38
+ | Insert (random int) | 5.994 ms (±0.6%) | 4.763 ms (±0.8%) | **−20.55%** ⚡ |
39
+ | Lookup (sequential int, 3x) | 12.204 ms (±0.1%) | 11.050 ms (±0.1%) | **−9.46%** ⚡ |
40
+ | Lookup (string keys, 3x) | 20.465 ms (±0.2%) | 20.322 ms (±0.2%) | **−0.70%** ⚡ |
41
+ | Delete + reinsert 25% | 8.968 ms (±0.3%) | 7.075 ms (±0.4%) | **−21.10%** ⚡ |
42
+ | Mixed (70/20/10 R/W/D) | 22.168 ms (±0.3%) | 18.818 ms (±0.1%) | **−15.11%** ⚡ |
43
43
 
44
44
  ### N = 10,000
45
45
 
46
46
  | Operation | Ruby Hash | SwissHash | Delta |
47
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%** ⚡ |
48
+ | Insert (sequential int) | 0.577 ms (±1.2%) | 0.499 ms (±1.5%) | **−13.58%** ⚡ |
49
+ | Insert (string keys) | 1.479 ms (±1.6%) | 1.001 ms (±1.3%) | **−32.30%** ⚡ |
50
+ | Insert (random int) | 0.541 ms (±1.9%) | 0.470 ms (±2.2%) | **−13.10%** ⚡ |
51
+ | Lookup (sequential int, 3x) | 1.069 ms (±0.2%) | 1.035 ms (±0.2%) | **−3.22%** ⚡ |
52
+ | Lookup (string keys, 3x) | 1.692 ms (±0.4%) | 1.550 ms (±0.4%) | **−8.42%** ⚡ |
53
+ | Delete + reinsert 25% | 0.808 ms (±0.9%) | 0.701 ms (±0.7%) | **−13.33%** ⚡ |
54
+ | Mixed (70/20/10 R/W/D) | 1.979 ms (±0.6%) | 1.829 ms (±0.8%) | **−7.62%** ⚡ |
55
55
 
56
56
  ### N = 1,000
57
57
 
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.
59
-
60
58
  | Operation | Ruby Hash | SwissHash | Delta |
61
59
  |---|---:|---:|---:|
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%** ⚡ |
60
+ | Insert (sequential int) | 0.057 ms (±0.8%) | 0.053 ms (±1.3%) | **−6.53%** ⚡ |
61
+ | Insert (string keys) | 0.154 ms (±2.7%) | 0.103 ms (±0.9%) | **−32.69%** ⚡ |
62
+ | Insert (random int) | 0.054 ms (±1.4%) | 0.049 ms (±1.1%) | **−9.27%** ⚡ |
63
+ | Lookup (sequential int, 3x) | 0.110 ms (±0.5%) | 0.106 ms (±0.4%) | **−3.73%** ⚡ |
64
+ | Lookup (string keys, 3x) | 0.177 ms (±0.5%) | 0.151 ms (±0.4%) | **−14.37%** ⚡ |
65
+ | Delete + reinsert 25% | 0.081 ms (±0.8%) | 0.074 ms (±1.1%) | **−8.42%** ⚡ |
66
+ | Mixed (70/20/10 R/W/D) | 0.203 ms (±0.2%) | 0.191 ms (±0.3%) | **−5.88%** ⚡ |
69
67
 
70
68
  ### Summary
71
69
 
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).
70
+ - SwissHash is faster on all seven listed operations at N=100k; string lookup is within 1% of Ruby Hash.
71
+ - The strongest win is string-key insertion: **−32% to −35%** across tested sizes.
75
72
  - ARM64 uses the NEON group-matching path (`stats[:simd] => "NEON"`).
76
73
  - Ruby's built-in `Hash` remains excellent, especially for very small maps and cases that benefit from VM-level Hash specialization.
77
74
 
78
75
  ### Memory Usage
79
76
 
80
- For 100,000 integer keys in the current benchmark:
77
+ For 100,000 entries, `ObjectSpace.memsize_of` reports the hash object/table and directly retained key/value objects. Integer keys and values are immediate values, so they add no separate object bytes. For string-key tests, stored string keys are counted; the shared input array and source strings are excluded from both totals.
81
78
 
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 |
79
+ | Workload | Implementation | Container | Referenced keys/values | Total |
80
+ |---|---|---:|---:|---:|
81
+ | Integer keys and values | SwissHash | 2,176.1 KiB | 0.0 KiB | 2,176.1 KiB |
82
+ | Integer keys and values | Ruby Hash | 4,096.2 KiB | 0.0 KiB | 4,096.2 KiB |
83
+ | String keys and integer values | SwissHash | 2,176.1 KiB | 6,408.1 KiB | 8,584.2 KiB |
84
+ | String keys and integer values | Ruby Hash | 4,096.2 KiB | 6,408.1 KiB | 10,504.3 KiB |
86
85
 
87
- Additional stats: load factor 76.3%, max load factor 87.5%, SIMD path reported as NEON on the benchmarked Apple Silicon machine.
86
+ SwissHash uses 46.87% less measured memory for integer entries and 18.28% less for string-key entries. The measurement excludes allocator fragmentation and objects reachable only through nested values. Load factor: 76.3%; max load factor: 87.5%; SIMD path: NEON.
88
87
 
89
88
  ## Features
90
89
 
@@ -451,32 +451,30 @@ static void swiss_free_arrays(SwissHash *sh) {
451
451
  }
452
452
 
453
453
  static void swiss_init(SwissHash *sh, size_t min_capacity) {
454
- if (sh->ctrl) {
455
- swiss_free_arrays(sh);
456
- }
457
-
458
- size_t min_groups = (min_capacity + GROUP_SIZE - 1) / GROUP_SIZE;
454
+ size_t min_groups = min_capacity / GROUP_SIZE + (min_capacity % GROUP_SIZE != 0);
459
455
  size_t num_groups = 1;
460
- while (num_groups < min_groups)
456
+ while (num_groups < min_groups) {
457
+ if (num_groups > SIZE_MAX / 2) {
458
+ rb_raise(rb_eArgError, "SwissHash capacity is too large");
459
+ }
461
460
  num_groups <<= 1;
461
+ }
462
462
  if (num_groups < 2)
463
463
  num_groups = 2;
464
464
 
465
+ if (num_groups > SIZE_MAX / GROUP_SIZE) {
466
+ rb_raise(rb_eArgError, "SwissHash capacity is too large");
467
+ }
465
468
  size_t capacity = num_groups * GROUP_SIZE;
466
469
  uint8_t *ctrl = NULL;
467
470
  Slot *slots = NULL;
468
471
 
469
472
  if (!swiss_alloc_block(capacity, &ctrl, &slots)) {
470
- sh->capacity = 0;
471
- sh->num_groups = 0;
472
- sh->group_mask = 0;
473
- sh->size = 0;
474
- sh->growth_left = 0;
475
- sh->tombstone_count = 0;
476
- sh->mutating = 0;
477
473
  rb_raise(rb_eNoMemError, "failed to allocate SwissHash");
478
474
  }
479
475
 
476
+ memset(ctrl, CTRL_EMPTY, capacity);
477
+ uint8_t *old_ctrl = sh->ctrl;
480
478
  sh->ctrl = ctrl;
481
479
  sh->slots = slots;
482
480
  sh->num_groups = num_groups;
@@ -486,7 +484,7 @@ static void swiss_init(SwissHash *sh, size_t min_capacity) {
486
484
  sh->tombstone_count = 0;
487
485
  sh->mutating = 0;
488
486
  sh->growth_left = capacity * MAX_LOAD_NUM / MAX_LOAD_DEN;
489
- memset(sh->ctrl, CTRL_EMPTY, capacity);
487
+ free(old_ctrl);
490
488
  }
491
489
 
492
490
  typedef struct {
@@ -1019,6 +1017,10 @@ static VALUE swiss_hash_initialize(int argc, VALUE *argv, VALUE self) {
1019
1017
  SwissHash *sh;
1020
1018
  TypedData_Get_Struct(self, SwissHash, &swiss_hash_type, sh);
1021
1019
 
1020
+ if (sh->mutating) {
1021
+ rb_raise(rb_eRuntimeError, "SwissHash: reentrant modification detected");
1022
+ }
1023
+
1022
1024
  size_t capacity = NIL_P(capacity_val) ? 16 : NUM2SIZET(capacity_val);
1023
1025
  swiss_init(sh, capacity);
1024
1026
 
@@ -1035,10 +1037,6 @@ static VALUE swiss_hash_initialize_copy(VALUE self, VALUE original) {
1035
1037
  TypedData_Get_Struct(original, SwissHash, &swiss_hash_type, src);
1036
1038
  TypedData_Get_Struct(self, SwissHash, &swiss_hash_type, dst);
1037
1039
 
1038
- if (dst->ctrl || dst->slots) {
1039
- swiss_free_arrays(dst);
1040
- }
1041
-
1042
1040
  if (!src->ctrl || !src->slots) {
1043
1041
  swiss_init(dst, 16);
1044
1042
  return self;
@@ -1057,6 +1055,10 @@ static VALUE swiss_hash_initialize_copy(VALUE self, VALUE original) {
1057
1055
 
1058
1056
  static VALUE swiss_hash_aset(VALUE self, VALUE key, VALUE value) {
1059
1057
  SwissHash *sh = (SwissHash *)RTYPEDDATA_DATA(self);
1058
+ if (RB_UNLIKELY(sh->mutating)) {
1059
+ rb_raise(rb_eRuntimeError, "SwissHash: reentrant modification detected "
1060
+ "(#hash or #eql? callback modified the same table)");
1061
+ }
1060
1062
  if (FIXNUM_P(key)) {
1061
1063
  return swiss_insert_fixnum(sh, key, value);
1062
1064
  }
@@ -1098,6 +1100,11 @@ static VALUE swiss_hash_clear(VALUE self) {
1098
1100
  SwissHash *sh;
1099
1101
  TypedData_Get_Struct(self, SwissHash, &swiss_hash_type, sh);
1100
1102
 
1103
+ if (RB_UNLIKELY(sh->mutating)) {
1104
+ rb_raise(rb_eRuntimeError, "SwissHash: reentrant modification detected "
1105
+ "(#hash or #eql? callback modified the same table)");
1106
+ }
1107
+
1101
1108
  memset(sh->ctrl, CTRL_EMPTY, sh->capacity);
1102
1109
  memset(sh->slots, 0, sh->capacity * sizeof(Slot));
1103
1110
  sh->size = 0;
@@ -1372,8 +1379,16 @@ static VALUE swiss_hash_merge(int argc, VALUE *argv, VALUE self) {
1372
1379
  }
1373
1380
 
1374
1381
  static VALUE swiss_hash_replace(VALUE self, VALUE other) {
1382
+ if (self == other) {
1383
+ return self;
1384
+ }
1385
+
1386
+ VALUE converted = rb_check_hash_type(other);
1387
+ if (NIL_P(converted) && !RTEST(rb_obj_is_kind_of(other, cSwissHashHash))) {
1388
+ rb_raise(rb_eTypeError, "no implicit conversion of %s into Hash", rb_obj_classname(other));
1389
+ }
1375
1390
  swiss_hash_clear(self);
1376
- VALUE argv[1] = {other};
1391
+ VALUE argv[1] = {NIL_P(converted) ? other : converted};
1377
1392
  swiss_hash_merge_bang(1, argv, self);
1378
1393
  return self;
1379
1394
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwissHash
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swiss_hash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haidarov