diff options
author | John Hawthorn <[email protected]> | 2025-03-06 16:16:35 -0800 |
---|---|---|
committer | John Hawthorn <[email protected]> | 2025-04-18 13:03:54 +0900 |
commit | 57b6a7503f19a9ffb53b1c505c7e093d7a6e33a4 (patch) | |
tree | 37c8acfcfc009aca01fa9cbf615dccbdac0ad3dd /string.c | |
parent | b28363a8381fd4e93d1254e71486e0185a84c9c5 (diff) |
Lock-free hash set for fstrings [Feature #21268]
This implements a hash set which is wait-free for lookup and lock-free
for insert (unless resizing) to use for fstring de-duplication.
As highlighted in https://bugs.ruby-lang.org/issues/19288, heavy use of
fstrings (frozen interned strings) can significantly reduce the
parallelism of Ractors.
I tried a few other approaches first: using an RWLock, striping a series
of RWlocks (partitioning the hash N-ways to reduce lock contention), and
putting a cache in front of it. All of these improved the situation, but
were unsatisfying as all still required locks for writes (and granular
locks are awkward, since we run the risk of needing to reach a vm
barrier) and this table is somewhat write-heavy.
My main reference for this was Cliff Click's talk on a lock free
hash-table for java https://www.youtube.com/watch?v=HJ-719EGIts. It
turns out this lock-free hash set is made easier to implement by a few
properties:
* We only need a hash set rather than a hash table (we only need keys,
not values), and so the full entry can be written as a single VALUE
* As a set we only need lookup/insert/delete, no update
* Delete is only run inside GC so does not need to be atomic (It could
be made concurrent)
* I use rb_vm_barrier for the (rare) table rebuilds (It could be made
concurrent) We VM lock (but don't require other threads to stop) for
table rebuilds, as those are rare
* The conservative garbage collector makes deferred replication easy,
using a T_DATA object
Another benefits of having a table specific to fstrings is that we
compare by value on lookup/insert, but by identity on delete, as we only
want to remove the exact string which is being freed. This is faster and
provides a second way to avoid the race condition in
https://bugs.ruby-lang.org/issues/21172.
This is a pretty standard open-addressing hash table with quadratic
probing. Similar to our existing st_table or id_table. Deletes (which
happen on GC) replace existing keys with a tombstone, which is the only
type of update which can occur. Tombstones are only cleared out on
resize.
Unlike st_table, the VALUEs are stored in the hash table itself
(st_table's bins) rather than as a compact index. This avoids an extra
pointer dereference and is possible because we don't need to preserve
insertion order. The table targets a load factor of 2 (it is enlarged
once it is half full).
Notes
Notes:
Merged: https://github.com/ruby/ruby/pull/12921
Diffstat (limited to 'string.c')
-rw-r--r-- | string.c | 512 |
1 files changed, 412 insertions, 100 deletions
@@ -383,11 +383,6 @@ fstring_hash(VALUE str) #define fstring_hash rb_str_hash #endif -const struct st_hash_type rb_fstring_hash_type = { - fstring_cmp, - fstring_hash, -}; - #define BARE_STRING_P(str) (!FL_ANY_RAW(str, FL_EXIVAR) && RBASIC_CLASS(str) == rb_cString) static inline st_index_t @@ -421,90 +416,72 @@ str_store_precomputed_hash(VALUE str, st_index_t hash) } struct fstr_update_arg { - VALUE fstr; bool copy; bool force_precompute_hash; }; -static int -fstr_update_callback(st_data_t *key, st_data_t *value, st_data_t data, int existing) -{ - struct fstr_update_arg *arg = (struct fstr_update_arg *)data; - VALUE str = (VALUE)*key; - - if (existing) { - /* because of lazy sweep, str may be unmarked already and swept - * at next time */ +static VALUE build_fstring(VALUE str, struct fstr_update_arg *arg) { + // Unless the string is empty or binary, its coderange has been precomputed. + int coderange = ENC_CODERANGE(str); - if (rb_objspace_garbage_object_p(str)) { - arg->fstr = Qundef; - // When RSTRING_FSTR strings are swept, they call `st_delete`. - // To avoid a race condition if an equivalent string was inserted - // we must remove the flag immediately. - FL_UNSET_RAW(str, RSTRING_FSTR); - return ST_DELETE; - } - - arg->fstr = str; - return ST_STOP; - } - else { - // Unless the string is empty or binary, its coderange has been precomputed. - int coderange = ENC_CODERANGE(str); - - if (FL_TEST_RAW(str, STR_FAKESTR)) { - if (arg->copy) { - VALUE new_str; - long len = RSTRING_LEN(str); - long capa = len + sizeof(st_index_t); - int term_len = TERM_LEN(str); - - if (arg->force_precompute_hash && STR_EMBEDDABLE_P(capa, term_len)) { - new_str = str_alloc_embed(rb_cString, capa + term_len); - memcpy(RSTRING_PTR(new_str), RSTRING_PTR(str), len); - STR_SET_LEN(new_str, RSTRING_LEN(str)); - TERM_FILL(RSTRING_END(new_str), TERM_LEN(str)); - rb_enc_copy(new_str, str); - str_store_precomputed_hash(new_str, str_do_hash(str)); - } - else { - new_str = str_new(rb_cString, RSTRING(str)->as.heap.ptr, RSTRING(str)->len); - rb_enc_copy(new_str, str); -#ifdef PRECOMPUTED_FAKESTR_HASH - if (rb_str_capacity(new_str) >= RSTRING_LEN(str) + term_len + sizeof(st_index_t)) { - str_store_precomputed_hash(new_str, (st_index_t)RSTRING(str)->as.heap.aux.capa); - } -#endif - } - str = new_str; + if (FL_TEST_RAW(str, STR_FAKESTR)) { + if (arg->copy) { + VALUE new_str; + long len = RSTRING_LEN(str); + long capa = len + sizeof(st_index_t); + int term_len = TERM_LEN(str); + + if (arg->force_precompute_hash && STR_EMBEDDABLE_P(capa, term_len)) { + new_str = str_alloc_embed(rb_cString, capa + term_len); + memcpy(RSTRING_PTR(new_str), RSTRING_PTR(str), len); + STR_SET_LEN(new_str, RSTRING_LEN(str)); + TERM_FILL(RSTRING_END(new_str), TERM_LEN(str)); + rb_enc_copy(new_str, str); + str_store_precomputed_hash(new_str, str_do_hash(str)); } else { - str = str_new_static(rb_cString, RSTRING(str)->as.heap.ptr, - RSTRING(str)->len, - ENCODING_GET(str)); + new_str = str_new(rb_cString, RSTRING(str)->as.heap.ptr, RSTRING(str)->len); + rb_enc_copy(new_str, str); +#ifdef PRECOMPUTED_FAKESTR_HASH + if (rb_str_capacity(new_str) >= RSTRING_LEN(str) + term_len + sizeof(st_index_t)) { + str_store_precomputed_hash(new_str, (st_index_t)RSTRING(str)->as.heap.aux.capa); + } +#endif } - OBJ_FREEZE(str); + str = new_str; } else { - if (!OBJ_FROZEN(str) || CHILLED_STRING_P(str)) { - str = str_new_frozen(rb_cString, str); - } - if (STR_SHARED_P(str)) { /* str should not be shared */ - /* shared substring */ - str_make_independent(str); - RUBY_ASSERT(OBJ_FROZEN(str)); - } - if (!BARE_STRING_P(str)) { - str = str_new_frozen(rb_cString, str); - } + str = str_new_static(rb_cString, RSTRING(str)->as.heap.ptr, + RSTRING(str)->len, + ENCODING_GET(str)); + } + OBJ_FREEZE(str); + } + else { + if (!OBJ_FROZEN(str) || CHILLED_STRING_P(str)) { + str = str_new_frozen(rb_cString, str); } + if (STR_SHARED_P(str)) { /* str should not be shared */ + /* shared substring */ + str_make_independent(str); + RUBY_ASSERT(OBJ_FROZEN(str)); + } + if (!BARE_STRING_P(str)) { + str = str_new_frozen(rb_cString, str); + } + } - ENC_CODERANGE_SET(str, coderange); - RBASIC(str)->flags |= RSTRING_FSTR; + ENC_CODERANGE_SET(str, coderange); + RBASIC(str)->flags |= RSTRING_FSTR; - *key = *value = arg->fstr = str; - return ST_CONTINUE; - } + RUBY_ASSERT(RB_TYPE_P(str, T_STRING)); + RUBY_ASSERT(OBJ_FROZEN(str)); + RUBY_ASSERT(!FL_TEST_RAW(str, STR_FAKESTR)); + RUBY_ASSERT(!FL_TEST_RAW(str, FL_EXIVAR)); + RUBY_ASSERT(RBASIC_CLASS(str) == rb_cString); + RUBY_ASSERT(!rb_objspace_garbage_object_p(str)); + + return str; } VALUE @@ -544,6 +521,298 @@ rb_fstring(VALUE str) return fstr; } +#define FSTRING_TABLE_EMPTY Qfalse +#define FSTRING_TABLE_TOMBSTONE Qtrue +#define FSTRING_TABLE_MOVED Qundef + +struct fstring_table_entry { + VALUE str; + VALUE hash; +}; + +struct fstring_table_struct { + struct fstring_table_entry *entries; + int capacity; + int deleted_entries; + rb_atomic_t count; // TODO: pad to own cache line? +}; + +static void fstring_table_free(void *ptr) { + struct fstring_table_struct *table = ptr; + xfree(table->entries); +} + +// We declare a type for the table so that we can lean on Ruby's GC for deferred reclamation +static const rb_data_type_t fstring_table_type = { + .wrap_struct_name = "fstring_table", + .function = { + .dmark = NULL, + .dfree = fstring_table_free, + .dsize = NULL, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_EMBEDDABLE +}; + + +static VALUE fstring_table_obj; + +static VALUE +new_fstring_table(int capacity) { + VALUE obj; + struct fstring_table_struct *table; + obj = TypedData_Make_Struct(0, struct fstring_table_struct, &fstring_table_type, table); + table->capacity = capacity; + table->count = 0; + table->entries = ZALLOC_N(struct fstring_table_entry, capacity); + return obj; +} + +void Init_fstring_table(void) { + fstring_table_obj = new_fstring_table(8192); + rb_gc_register_address(&fstring_table_obj); +} + +#if 0 + +// Linear probe +struct fstring_table_probe { + int idx; + int mask; +}; + +static int fstring_table_probe_start(struct fstring_table_probe *probe, struct fstring_table_struct *table, VALUE hash_code) { + RUBY_ASSERT((table->capacity & (table->capacity - 1)) == 0); + probe->mask = table->capacity - 1; + probe->idx = hash_code & probe->mask; + return probe->idx; +} + +static int fstring_table_probe_next(struct fstring_table_probe *probe) { + probe->idx = (probe->idx + 1) & probe->mask; + return probe->idx; +} + +#else + +// Struct containing probe information. Intended that the compiler should always inline this +// Quadratic probing +struct fstring_table_probe { + int idx; + int d; + int mask; +}; + +static int fstring_table_probe_start(struct fstring_table_probe *probe, struct fstring_table_struct *table, VALUE hash_code) { + RUBY_ASSERT((table->capacity & (table->capacity - 1)) == 0); + probe->d = 0; + probe->mask = table->capacity - 1; + probe->idx = hash_code & probe->mask; + return probe->idx; +} + +static int fstring_table_probe_next(struct fstring_table_probe *probe) { + probe->d++; + probe->idx = (probe->idx + probe->d) & probe->mask; + return probe->idx; +} +#endif + +#define RUBY_ATOMIC_VALUE_LOAD(x) (VALUE)(RUBY_ATOMIC_PTR_LOAD(x)) + +static void fstring_insert_on_resize(struct fstring_table_struct *table, VALUE hash_code, VALUE value) { + struct fstring_table_probe probe; + int idx = fstring_table_probe_start(&probe, table, hash_code); + + for (;;) { + struct fstring_table_entry *entry = &table->entries[idx]; + VALUE candidate = entry->str; + + RUBY_ASSERT(candidate != FSTRING_TABLE_TOMBSTONE); + RUBY_ASSERT(candidate != FSTRING_TABLE_MOVED); + + if (candidate == FSTRING_TABLE_EMPTY) { + table->count++; + + RUBY_ASSERT(table->count < table->capacity / 2); + RUBY_ASSERT(entry->hash == 0); + + entry->str = value; + entry->hash = hash_code; + return; + } + + idx = fstring_table_probe_next(&probe); + } +} + +// Rebuilds the table +static void fstring_try_resize(VALUE old_table_obj) { + RB_VM_LOCK_ENTER(); + + // Check if another thread has already resized + if (RUBY_ATOMIC_VALUE_LOAD(fstring_table_obj) != old_table_obj) { + goto end; + } + + struct fstring_table_struct *old_table = RTYPEDDATA_GET_DATA(old_table_obj); + + // This may overcount by up to the number of threads concurrently attempting to insert + // GC may also happen between now and the table being rebuilt + int expected_count = RUBY_ATOMIC_LOAD(old_table->count) - old_table->deleted_entries; + + struct fstring_table_entry *old_entries = old_table->entries; + int old_capacity = old_table->capacity; + int new_capacity = old_capacity * 2; + if (new_capacity > expected_count * 8) { + new_capacity = old_capacity / 2; + } + else if (new_capacity > expected_count * 4) { + new_capacity = old_capacity; + } + + // May cause GC and therefore deletes, so must hapen first + VALUE new_table_obj = new_fstring_table(new_capacity); + struct fstring_table_struct *new_table = RTYPEDDATA_GET_DATA(new_table_obj); + + for (int i = 0; i < old_capacity; i++) { + struct fstring_table_entry *entry = &old_entries[i]; + VALUE val = RUBY_ATOMIC_VALUE_EXCHANGE(entry->str, FSTRING_TABLE_MOVED); + RUBY_ASSERT(val != FSTRING_TABLE_MOVED); + if (val == FSTRING_TABLE_EMPTY) continue; + if (val == FSTRING_TABLE_TOMBSTONE) continue; + if (rb_objspace_garbage_object_p(val)) continue; + + VALUE hash_code = RUBY_ATOMIC_VALUE_LOAD(entry->hash); + if (hash_code == 0) { + // Either in-progress insert or extremely unlikely 0 hash + // Re-calculate the hash ourselves + hash_code = fstring_hash(val); + } + RUBY_ASSERT(hash_code == fstring_hash(val)); + fstring_insert_on_resize(new_table, hash_code, val); + } + +#if 0 + fprintf(stderr, "resized: %p(%i) -> %p(%i) (count: %i->%i)\n", old_table, old_table->capacity, new_table, new_table->capacity, old_table->count, new_table->count); +#endif + + RUBY_ATOMIC_VALUE_SET(fstring_table_obj, new_table_obj); + +end: + RB_GC_GUARD(old_table_obj); + RB_VM_LOCK_LEAVE(); +} + +static VALUE fstring_find_or_insert(VALUE hash_code, VALUE value, struct fstr_update_arg *arg) { + struct fstring_table_probe probe; + bool inserting = false; + int idx; + VALUE table_obj; + struct fstring_table_struct *table; + + retry: + table_obj = RUBY_ATOMIC_VALUE_LOAD(fstring_table_obj); + RUBY_ASSERT(table_obj); + table = RTYPEDDATA_GET_DATA(table_obj); + idx = fstring_table_probe_start(&probe, table, hash_code); + + for (;;) { + struct fstring_table_entry *entry = &table->entries[idx]; + VALUE candidate = RUBY_ATOMIC_VALUE_LOAD(entry->str); + + if (candidate == FSTRING_TABLE_EMPTY) { + // Not in table + if (!inserting) { + // Prepare a string suitable for inserting into the table + value = build_fstring(value, arg); + RUBY_ASSERT(hash_code == fstring_hash(value)); + inserting = true; + } + + int prev_count = RUBY_ATOMIC_FETCH_ADD(table->count, 1); + + if (UNLIKELY(prev_count > table->capacity / 2)) { + fstring_try_resize(table_obj); + goto retry; + } + + VALUE found = RUBY_ATOMIC_VALUE_CAS(entry->str, FSTRING_TABLE_EMPTY, value); + if (found == FSTRING_TABLE_EMPTY) { + // Success! Our value was inserted + + // Also set the hash code + RUBY_ATOMIC_VALUE_SET(entry->hash, hash_code); + + RB_GC_GUARD(table_obj); + return value; + } else { + // Nothing was inserted + RUBY_ATOMIC_DEC(table->count); // we didn't end up inserting + + // Another thread won the race, try again at the same location + continue; + } + } else if (candidate == FSTRING_TABLE_TOMBSTONE) { + // Deleted entry, continue searching + } else if (candidate == FSTRING_TABLE_MOVED) { + // Wait + RB_VM_LOCK_ENTER(); + RB_VM_LOCK_LEAVE(); + + goto retry; + } else { + VALUE candidate_hash = RUBY_ATOMIC_VALUE_LOAD(entry->hash); + if ((candidate_hash == hash_code || candidate_hash == 0) && !fstring_cmp(candidate, value)) { + // We've found a match + if (UNLIKELY(rb_objspace_garbage_object_p(candidate))) { + // This is a weakref table, so after marking but before sweeping is complete we may find a matching garbage object. + // Skip it and mark it as a tombstone to help other threads out + RUBY_ATOMIC_VALUE_CAS(entry->str, candidate, FSTRING_TABLE_TOMBSTONE); + + // Fall through and continue our search + } else { + RB_GC_GUARD(table_obj); + return candidate; + } + } + } + + idx = fstring_table_probe_next(&probe); + } +} + + +// Removes an fstring from the table. Compares by identity +static void fstring_delete(VALUE hash_code, VALUE value) { + // Delete is never called concurrently, so atomic operations are unnecessary + VALUE table_obj = RUBY_ATOMIC_VALUE_LOAD(fstring_table_obj); + RUBY_ASSERT_ALWAYS(table_obj); + struct fstring_table_struct *table = RTYPEDDATA_GET_DATA(table_obj); + + struct fstring_table_probe probe; + int idx = fstring_table_probe_start(&probe, table, hash_code); + + for (;;) { + struct fstring_table_entry *entry = &table->entries[idx]; + VALUE candidate = entry->str; + + // Allocations should only occur at the beginning of the resize + RUBY_ASSERT(candidate != FSTRING_TABLE_MOVED); + + if (candidate == FSTRING_TABLE_EMPTY) { + // We didn't find our string to delete + return; + } else if (candidate == value) { + // We found our string, replace it with a tombstone and increment the count + entry->str = FSTRING_TABLE_TOMBSTONE; + table->deleted_entries++; + return; + } + + idx = fstring_table_probe_next(&probe); + } +} + static VALUE register_fstring(VALUE str, bool copy, bool force_precompute_hash) { @@ -560,29 +829,71 @@ register_fstring(VALUE str, bool copy, bool force_precompute_hash) } #endif - RB_VM_LOCK_ENTER(); - { - st_table *frozen_strings = rb_vm_fstring_table(); - do { - args.fstr = str; - st_update(frozen_strings, (st_data_t)str, fstr_update_callback, (st_data_t)&args); - } while (UNDEF_P(args.fstr)); + VALUE hash_code = fstring_hash(str); + VALUE result = fstring_find_or_insert(hash_code, str, &args); + + RUBY_ASSERT(!rb_objspace_garbage_object_p(result)); + RUBY_ASSERT(RB_TYPE_P(result, T_STRING)); + RUBY_ASSERT(OBJ_FROZEN(result)); + RUBY_ASSERT(!FL_TEST_RAW(result, STR_FAKESTR)); + RUBY_ASSERT(!FL_TEST_RAW(result, FL_EXIVAR)); + RUBY_ASSERT(RBASIC_CLASS(result) == rb_cString); + + return result; +} + +void rb_fstring_foreach_with_replace(st_foreach_check_callback_func *func, st_update_callback_func *replace, st_data_t arg) { + // Assume locking and barrier (which there is no assert for) + ASSERT_vm_locking(); + + VALUE table_obj = RUBY_ATOMIC_VALUE_LOAD(fstring_table_obj); + if (!table_obj) { + // Table not yet initialized. Nothing to iterate over + return; } - RB_VM_LOCK_LEAVE(); + struct fstring_table_struct *table = RTYPEDDATA_GET_DATA(table_obj); - RUBY_ASSERT(OBJ_FROZEN(args.fstr)); - RUBY_ASSERT(!FL_TEST_RAW(args.fstr, STR_FAKESTR)); - RUBY_ASSERT(!FL_TEST_RAW(args.fstr, FL_EXIVAR)); - RUBY_ASSERT(RBASIC_CLASS(args.fstr) == rb_cString); + for (int i = 0; i < table->capacity; i++) { + VALUE key = table->entries[i].str; + if(key == FSTRING_TABLE_EMPTY) continue; + if(key == FSTRING_TABLE_TOMBSTONE) continue; - return args.fstr; + enum st_retval retval; + retval = (*func)(key, key, arg, 0); + + if (retval == ST_REPLACE && replace) { + st_data_t value = key; + retval = (*replace)(&key, &value, arg, TRUE); + table->entries[i].str = key; + } + switch (retval) { + case ST_REPLACE: + case ST_CONTINUE: + break; + case ST_CHECK: + rb_bug("unsupported"); + case ST_STOP: + return; + case ST_DELETE: + table->entries[i].str = FSTRING_TABLE_TOMBSTONE; + break; + } + } +} + +bool rb_obj_is_fstring_table(VALUE obj) { + ASSERT_vm_locking(); + + return obj == fstring_table_obj; } void rb_gc_free_fstring(VALUE obj) { + // Assume locking and barrier (which there is no assert for) ASSERT_vm_locking(); - st_data_t fstr = (st_data_t)obj; - st_delete(rb_vm_fstring_table(), &fstr, NULL); + VALUE str_hash = fstring_hash(obj); + fstring_delete(str_hash, obj); + RB_DEBUG_COUNTER_INC(obj_str_fstr); FL_UNSET(obj, RSTRING_FSTR); @@ -642,17 +953,14 @@ rb_fstring_cstr(const char *ptr) } static int -fstring_set_class_i(st_data_t key, st_data_t val, st_data_t arg) -{ - RBASIC_SET_CLASS((VALUE)key, (VALUE)arg); - return ST_CONTINUE; -} - -static int fstring_cmp(VALUE a, VALUE b) { long alen, blen; const char *aptr, *bptr; + + RUBY_ASSERT(RB_TYPE_P(a, T_STRING)); + RUBY_ASSERT(RB_TYPE_P(b, T_STRING)); + RSTRING_GETMEM(a, aptr, alen); RSTRING_GETMEM(b, bptr, blen); return (alen != blen || @@ -12636,8 +12944,12 @@ void Init_String(void) { rb_cString = rb_define_class("String", rb_cObject); - RUBY_ASSERT(rb_vm_fstring_table()); - st_foreach(rb_vm_fstring_table(), fstring_set_class_i, rb_cString); + struct fstring_table_struct *fstring_table = RTYPEDDATA_GET_DATA(fstring_table_obj); + for (int i = 0; i < fstring_table->capacity; i++) { + VALUE str = fstring_table->entries[i].str; + if (!str) continue; + RBASIC_SET_CLASS(str, rb_cString); + } rb_include_module(rb_cString, rb_mComparable); rb_define_alloc_func(rb_cString, empty_str_alloc); rb_define_singleton_method(rb_cString, "new", rb_str_s_new, -1); |