Skip to content

use unique pointers as hash set entries - #21

Open
TalBarYakar wants to merge 3 commits into
masterfrom
tal.ba/test/ram_reduction
Open

TalBarYakar wants to merge 3 commits into
masterfrom
tal.ba/test/ram_reduction

Conversation

@TalBarYakar

@TalBarYakar TalBarYakar commented Sep 24, 2026 •

Copy link
Copy Markdown

Memory impact

The unique-string optimization avoids shared-reference metadata for strings that occur only once, promoting them to the shared representation when reused.

The following comparison excludes unused array/object capacity from both versions and includes global string-table allocations and allocator rounding.

### Memory savings

These measurements exclude unused array/object capacity and include global string-table allocations and allocator rounding.

1 million unique strings — 25 bytes each

  • Before: 74.93 MB
  • After: 58.93 MB
  • Saved: 16.00 MB (21.35%)

1 million nested records with unique 25-byte strings

  • Before: 170.92 MB
  • After: 154.92 MB
  • Saved: 16.00 MB (9.36%)

Real-world countries/states/cities JSON

  • Before: 34.89 MB
  • After: 29.97 MB
  • Saved: 4.92 MB (14.09%)

Measured locally on macOS ARM64 with the libc allocator. Savings depend on string lengths and allocator size classes.


Note

High Risk
Core string lifetime, refcounting, and defrag semantics changed with tagged pointers and cache mutex on all clone/drop paths; bugs could cause use-after-free, leaks, or stale pointers under concurrency.

Overview
Reworks non–thread_safe heap string interning so the global hash set holds unique allocations until a second owner appears, then promotes to a tagged SharedHeader with a cache-owned ref count. Per-string AtomicU32 on Header is removed; clone/drop/intern take the cache guard and call upgrade / release, demoting back to unique when only the table entry remains.

reinit_shared_string_cache now shrinks the set instead of clearing it, so live strings and shared metadata stay valid. IString::defrag in-place relocates only unique entries (updates cache + value); shared strings are left pinned.

Docs and mem_allocated reflect the smaller unique footprint (tests expect 16 bytes for a lone heap string without thread_safe). New tests cover promote/demote, defrag, and optional concurrent promotion.

Reviewed by Cursor Bugbot for commit 347962d. Bugbot is set up for automated code reviews on this repo. Configure here.

@TalBarYakar TalBarYakar self-assigned this Sep 24, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2c28cc9. Configure here.

Comment thread src/unsafe_string.rs
.get_val(self.as_str())
.expect("live string missing from cache")
.upgrade()
.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent clone races on string cache

High Severity

Heap IString clone now looks up and mutates the global intern table, including Cell refcounts and WeakIString tags. IValue is still Sync, so concurrent clone (and mem_allocated) on the default ThreadUnsafe cache alias a raw &mut HashSet and race on those cells. Previously clone only did an atomic increment and did not touch the cache, so sharing interned strings across threads was defined; this path is now unsound and can corrupt the intern table.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c28cc9. Configure here.

Comment thread src/unsafe_string.rs
enum StringCache {
ThreadSafe(Mutex<HashSet<WeakIString>>),
ThreadUnsafe(HashSet<WeakIString>),
ThreadUnsafe(UnsafeCell<HashSet<WeakIString>>),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that comes to my mind(and I've jsut started reviewing it so take it with a grain of salt).
We mainly use now the ThreadSafe one, primarly because ASM. I don't know if you also need to change something around the ThreadSafe impl, but FYI

@AvivDavid23 AvivDavid23 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to have performance degridation, need to bench it.
Also please check my comments

Comment thread src/unsafe_string.rs

struct SharedHeader {
data: NonNull<Header>,
rc: Cell<u32>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rc is not Atomic anymore, which is fine with our usage since we are using currently the ThreadSafe varient. If one day we will go back this might bite us

Comment thread src/unsafe_string.rs
StringCache::ThreadSafe(_) => true,
StringCache::ThreadUnsafe(_) => false,
}
fn get_cache() -> &'static StringCache {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not returning mut anymore? Anyway the only usage is mutating it later(maybe I'm missing something)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignore.. Cell provides interior mutability

Comment thread src/unsafe_string.rs
*s_c = HashSet::new();
}
}
// The cache now owns reference counts; live entries must survive a reset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What? Why we don't reset the Cache anymore?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset is part of RedisJSON’s defrag cycle. This change prevents shared strings from being
relocated. How will shared-string defrag remain supported?

Comment thread src/unsafe_string.rs
unsafe { self.0.raw_copy() }
let cache = get_cache_guard();
cache
.get_val(self.as_str())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now each clone(and drop, function below) is much slower since we first need to hash the str to get the rc..
Not sure about the solution tough, pointing this out

Comment thread src/unsafe_string.rs

// Run in its own test process because cache initialization is process-wide.
#[test]
#[ignore = "run separately with --ignored --exact"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please verify its running the ci, this specific test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants