Skip to content

Commit 9c06e10

Browse files
authored
Merge pull request #6 from tomtomwombat/tomtomwombat/docs
better docs
2 parents 99fb1a6 + 4414793 commit 9c06e10

5 files changed

Lines changed: 172 additions & 86 deletions

File tree

bench/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ harness = false
2626
[[bench]]
2727
name = "eq"
2828
harness = false
29+
30+
[[bench]]
31+
name = "collection"
32+
harness = false

bench/benches/collection.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use ahash::AHashSet;
2+
use bench::*;
3+
use criterion::{
4+
black_box, criterion_group, criterion_main, measurement::WallTime, BenchmarkGroup, Criterion,
5+
};
6+
use std::hash::Hash;
7+
use std::str::FromStr;
8+
9+
const LENGTHS: &[usize] = &[64];
10+
11+
fn bench_hashset_inner<T: FromStr + Eq + Hash>(
12+
g: &mut BenchmarkGroup<'_, WallTime>,
13+
name: &'static str,
14+
min: usize,
15+
max: usize,
16+
string_array: &[String],
17+
indices: &[usize],
18+
) {
19+
let string_vec: Vec<_> = string_array
20+
.iter()
21+
.map(|s| T::from_str(s).map_err(|_| ()).unwrap())
22+
.collect();
23+
let strings: AHashSet<_> = string_array
24+
.iter()
25+
.map(|s| T::from_str(s).map_err(|_| ()).unwrap())
26+
.collect();
27+
28+
let strings = black_box(strings);
29+
let label = format!("{}-len={}-{}", name, min, max);
30+
31+
g.bench_function(&label, |b| {
32+
b.iter(|| {
33+
for &i in indices.iter() {
34+
let s = &string_vec[i];
35+
let _ = black_box(strings.contains(s));
36+
}
37+
})
38+
});
39+
}
40+
41+
#[rustfmt::skip]
42+
fn bench_hashset(c: &mut Criterion) {
43+
let mut group = c.benchmark_group("hashset");
44+
let count = 1_000_000;
45+
46+
let mut indices: Vec<usize> = (0..count).collect();
47+
fastrand::shuffle(&mut indices);
48+
let indices_subset = &indices[..1000];
49+
50+
for len in LENGTHS {
51+
for min in [0, *len] {
52+
let mut strings = Vec::with_capacity(count);
53+
for _ in 0..count {
54+
strings.push(random_string(min, *len));
55+
}
56+
bench_hashset_inner::<String>(&mut group, "std", min, *len, &strings, indices_subset);
57+
bench_hashset_inner::<smol_str::SmolStr>(&mut group, "smol_str", min, *len, &strings, indices_subset);
58+
bench_hashset_inner::<compact_str::CompactString>(&mut group, "compact_str", min, *len, &strings, indices_subset);
59+
bench_hashset_inner::<smartstring::alias::String>(&mut group, "smartstring", min, *len, &strings, indices_subset);
60+
bench_hashset_inner::<smallstr::SmallString<[u8; 8]>>(&mut group, "smallstr", min, *len, &strings, indices_subset);
61+
bench_hashset_inner::<compact_string::CompactString>(&mut group, "compact_string", min, *len, &strings, indices_subset);
62+
bench_hashset_inner::<cold_string::ColdString>(&mut group, "cold-string", min, *len, &strings, indices_subset);
63+
}
64+
}
65+
group.finish();
66+
}
67+
68+
criterion_group!(benches, bench_hashset);
69+
criterion_main!(benches);

bench/memory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def main():
3232
for file in sorted(csv_files):
3333
xs, ys = read_csv(file)
3434
label = os.path.splitext(os.path.basename(file))[0]
35-
plt.plot(xs, ys, label=label, linewidth=3.5, alpha = 0.75)
35+
plt.plot(xs, ys, label=label, linewidth=3.5, alpha = 1.0)
3636

3737
plt.xlabel("String Length")
3838
plt.ylabel("Memory Usage (bytes)")

bench/tests/memory.rs

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use bench::*;
55

6-
use ahash::{HashMap, HashMapExt};
6+
use ahash::{HashSet, HashSetExt};
77
use std::alloc::{GlobalAlloc, Layout, System};
88
use std::cmp::Ord;
99
use std::collections::BTreeMap;
@@ -78,10 +78,10 @@ fn test_allocator_memory() {
7878
allocator_memory::<cold_string::ColdString>("cold-string");
7979
}
8080

81-
fn hash_map_workload<T: FromStr + Hash + Eq>(min: usize, max: usize) {
82-
let mut strings: HashMap<T, T> = HashMap::with_capacity(TRIALS);
81+
fn hash_set_workload<T: FromStr + Hash + Eq>(min: usize, max: usize) {
82+
let mut strings: HashSet<T> = HashSet::with_capacity(TRIALS);
8383
for _ in 0..TRIALS {
84-
strings.insert(random_string(min, max), random_string(min, max));
84+
strings.insert(random_string(min, max));
8585
}
8686
let strings = std::hint::black_box(strings);
8787
std::mem::forget(strings);
@@ -148,17 +148,8 @@ fn system_memory(name: &str, workload: impl Fn(usize, usize)) {
148148
print!("\n");
149149
}
150150

151-
/// Not run automatically.
152-
/// Run with `cargo test test_system_memory --release -- --no-capture --include-ignored`
153-
/// Or specify min,max:
154-
/// ```
155-
/// cargo test test_system_memory --release -- --no-capture --include-ignored
156-
/// ```
157-
#[test]
158-
#[rustfmt::skip]
159-
#[ignore]
160-
fn test_system_memory() {
161-
print!("{:<NAME_WIDTH$} ", "Crate");
151+
fn print_table_header(title: &str) {
152+
print!("{:<NAME_WIDTH$} ", title);
162153
for &size in SIZES {
163154
print!(" | {:>CELL_WIDTH$}", format!("{}..={}", 0, size));
164155
}
@@ -169,12 +160,49 @@ fn test_system_memory() {
169160
print!(" {: ^CELL_WIDTH$} |", ":---:");
170161
}
171162
println!();
163+
}
172164

173-
system_memory("cold-string", hash_map_workload::<cold_string::ColdString>);
174-
system_memory("compact_str", hash_map_workload::<compact_str::CompactString>);
175-
system_memory("compact_string", hash_map_workload::<compact_string::CompactString>);
176-
system_memory("smallstr", hash_map_workload::<smallstr::SmallString<[u8; 8]>>);
177-
system_memory("smartstring", hash_map_workload::<smartstring::alias::String>);
178-
system_memory("smol_str", hash_map_workload::<smol_str::SmolStr>);
179-
system_memory("std", hash_map_workload::<String>);
165+
/// `cargo test test_system_memory_vec --release -- --no-capture --include-ignored`
166+
#[test]
167+
#[rustfmt::skip]
168+
#[ignore]
169+
fn test_system_memory_vec() {
170+
print_table_header("Vec");
171+
system_memory("cold-string", vec_workload::<cold_string::ColdString>);
172+
system_memory("compact_str", vec_workload::<compact_str::CompactString>);
173+
system_memory("compact_string", vec_workload::<compact_string::CompactString>);
174+
system_memory("smallstr", vec_workload::<smallstr::SmallString<[u8; 8]>>);
175+
system_memory("smartstring", vec_workload::<smartstring::alias::String>);
176+
system_memory("smol_str", vec_workload::<smol_str::SmolStr>);
177+
system_memory("std", vec_workload::<String>);
178+
}
179+
180+
/// `cargo test test_system_memory_hashset --release -- --no-capture --include-ignored`
181+
#[test]
182+
#[rustfmt::skip]
183+
#[ignore]
184+
fn test_system_memory_hashset() {
185+
print_table_header("HashSet");
186+
system_memory("cold-string", hash_set_workload::<cold_string::ColdString>);
187+
system_memory("compact_str", hash_set_workload::<compact_str::CompactString>);
188+
system_memory("compact_string", hash_set_workload::<compact_string::CompactString>);
189+
system_memory("smallstr", hash_set_workload::<smallstr::SmallString<[u8; 8]>>);
190+
system_memory("smartstring", hash_set_workload::<smartstring::alias::String>);
191+
system_memory("smol_str", hash_set_workload::<smol_str::SmolStr>);
192+
system_memory("std", hash_set_workload::<String>);
193+
}
194+
195+
/// `cargo test test_system_memory_btreeset --release -- --no-capture --include-ignored`
196+
#[test]
197+
#[rustfmt::skip]
198+
#[ignore]
199+
fn test_system_memory_btreeset() {
200+
print_table_header("BTreeSet");
201+
system_memory("cold-string", btree_workload::<cold_string::ColdString>);
202+
system_memory("compact_str", btree_workload::<compact_str::CompactString>);
203+
system_memory("compact_string", btree_workload::<compact_string::CompactString>);
204+
system_memory("smallstr", btree_workload::<smallstr::SmallString<[u8; 8]>>);
205+
system_memory("smartstring", btree_workload::<smartstring::alias::String>);
206+
system_memory("smol_str", btree_workload::<smol_str::SmolStr>);
207+
system_memory("std", btree_workload::<String>);
180208
}

cold-string/README.md

Lines changed: 48 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,27 @@
33
[![Crates.io](https://img.shields.io/badge/crates.io-fc8d62?style=for-the-badge&labelColor=555555&logo=rust)](https://crates.io/crates/cold-string)
44
[![docs.rs](https://img.shields.io/badge/docs.rs-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs)](https://docs.rs/cold-string)
55
![MSRV](https://img.shields.io/crates/msrv/cold-string?style=for-the-badge)
6-
![Downloads](https://img.shields.io/crates/d/cold-string?style=for-the-badge)
76

87
A 1-word (8-byte) sized representation of immutable UTF-8 strings that in-lines up to 8 bytes. Optimized for memory usage and struct packing.
98

10-
# Overview
9+
## Overview
1110

12-
`ColdString` is optimized for memory efficiency for **large** and **short** strings:
13-
- 0..=8 bytes: always 8 bytes total (fully inlined).
14-
- 9..=128 bytes: 8-byte pointer + 1-byte length encoding
15-
- 129..=16384 bytes: 8-byte pointer + 2-byte length encoding
16-
- Continues logarithmically up to 18 bytes overhead for sizes up to `isize::MAX`.
11+
`ColdString` minimizes per-string overhead for both **short and large** strings.
12+
- Strings ≤ 8 bytes: **8 bytes total**
13+
- Larger strings: **~9–10 bytes overhead** (other string libraries have 24 bytes per value)
1714

18-
Compared to `String`, which stores capacity and length inline (3 machine words), `ColdString` avoids storing length inline for heap strings and compresses metadata into tagged pointer space. This leads to substantial memory savings in benchmarks (see [Memory Comparison (System RSS)](#memory-comparison-system-rss)):
19-
- **36% – 68%** smaller than `String` in `HashMap`
20-
- **28% – 65%** smaller than other short-string crates in `HashMap`
15+
This leads to substantial memory savings over both `String` and other short-string crates (see [Memory Comparison (System RSS)](#memory-comparison-system-rss)):
16+
- **35% – 67%** smaller than `String` in `HashSet`
17+
- **35% – 64%** smaller than other short-string crates in `HashSet`
2118
- **30% – 75%** smaller than `String` in `BTreeSet`
2219
- **13% – 63%** smaller than other short-string crates in `BTreeSet`
2320

24-
`ColdString`'s MSRV is 1.60, is `no_std` compatible, and is a drop in replacement for immutable Strings.
25-
26-
### Safety
27-
`ColdString` is written using [Rust's strict provenance API](https://doc.rust-lang.org/beta/std/ptr/index.html#strict-provenance), carefully handles unaligned access internally, and is validated with property testing and MIRI.
21+
---
2822

29-
### Why "Cold"?
30-
31-
The heap representation stores the length on the heap, not inline in the struct. This saves memory in the struct itself but *slightly* increases the cost of `len()` since it requires a heap read. In practice, the `len()` cost is only marginally slower than inline storage and is typically negligible compared to:
32-
- Memory savings
33-
- Cache density improvements
34-
- Faster collection operations due to reduced footprint
23+
### Portability
24+
`ColdString`'s MSRV is 1.60, is `no_std` compatible, and is a drop in replacement for immutable Strings.
3525

36-
# Usage
26+
## Usage
3727

3828
Use it like a `String`:
3929
```rust
@@ -45,57 +35,54 @@ assert_eq!(s.as_str(), "qwerty");
4535

4636
Packs well with other types:
4737
```rust
48-
use std::mem;
4938
use cold_string::ColdString;
39+
use std::mem::{align_of, size_of};
5040

51-
assert_eq!(mem::size_of::<ColdString>(), mem::size_of::<usize>());
52-
assert_eq!(mem::align_of::<ColdString>(), 1);
41+
assert_eq!(size_of::<ColdString>(), size_of::<usize>());
42+
assert_eq!(align_of::<ColdString>(), 1);
5343

54-
assert_eq!(mem::size_of::<(ColdString, u8)>(), mem::size_of::<usize>() + 1);
55-
assert_eq!(mem::align_of::<(ColdString, u8)>(), 1);
44+
assert_eq!(size_of::<(ColdString, u8)>(), size_of::<usize>() + 1);
45+
assert_eq!(size_of::<Option<ColdString>>(), size_of::<usize>() + 1);
5646
```
5747

58-
# How It Works
48+
## How It Works
5949

60-
ColdString is 8-byte tagged pointer (4 bytes on 32-bit machines):
50+
ColdString is an 8-byte tagged pointer (4 bytes on 32-bit machines):
6151
```rust
6252
#[repr(packed)]
6353
pub struct ColdString {
64-
/// The first byte of `encoded` is the "tag" and it determines the type:
65-
/// - 10xxxxxx: an encoded address for the heap. To decode, 10 is set to 00 and swapped
66-
/// with the LSB bits of the tag byte. The address is always a multiple of 4 (`HEAP_ALIGN`).
67-
/// - 11111xxx: xxx is the length in range 0..=7, followed by length UTF-8 bytes.
68-
/// - xxxxxxxx (valid UTF-8): 8 UTF-8 bytes.
6954
encoded: *mut u8,
7055
}
7156
```
72-
`encoded` acts as either a pointer to the heap for strings longer than 8 bytes or is the inlined data itself. The first/"tag" byte indicates one of 3 encodings:
57+
The 8 bytes encode one of three representations indicated by the 1st byte:
58+
- `10xxxxxx`: `encoded` contains a tagged heap pointer. To decode the address, clear the tag bits (`10 → 00`) and rotate so the `00` bits become the least-significant bits. The heap allocation uses [4-byte alignment](https://doc.rust-lang.org/beta/std/alloc/struct.Layout.html#method.from_size_align), guaranteeing the
59+
least-significant 2 bits of the address are `00`. On the heap, the UTF-8 characters are preceded by the variable-length encoding of the size. The size uses 1 byte for 0 - 127, 2 bytes for 128 - 16383, etc.
60+
- `11111xxx`: xxx is the length and the remaining 0-7 bytes are UTF-8 characters.
61+
- `xxxxxxxx`: All 8 bytes are UTF-8.
7362

74-
### Inline Mode (0 to 7 Bytes)
75-
The tag byte has bits 11111xxx, where xxx is the length. `self.0[1]` to `self.0[7]` store the bytes of string.
63+
`10xxxxxx` and `11111xxx` are chosen because they cannot be valid first bytes of UTF-8.
7664

77-
### Inline Mode (8 Bytes)
78-
The tag byte is any valid UTF-8 byte. `self.0` stores the bytes of string. Since the string is UTF-8, the tag byte is guaranteed to not be 10xxxxx or 11111xxx.
65+
### Why "Cold"?
7966

80-
### Heap Mode
81-
`self.0` encodes the pointer to heap, where tag byte is 10xxxxxx. 10xxxxxx is chosen because it's a UTF-8 continuation byte and therefore an impossible tag byte for inline mode. Since a heap-alignment of 4 is chosen, the pointer's least significant 2 bits are guaranteed to be 0 ([See more](https://doc.rust-lang.org/beta/std/alloc/struct.Layout.html#method.from_size_align)). These bits are swapped with the 10 "tag" bits when de/coding between `self.0` and the address value.
67+
The heap representation stores the length on the heap, not inline in the struct. This saves memory in the struct itself but *slightly* increases the cost of `len()` since it requires a heap read. In practice, the `len()` cost is only marginally slower than inline storage and is typically negligible compared to memory savings, cache density improvements, and 3x faster operations on inlined strings.
8268

83-
On the heap, the data starts with a variable length integer encoding of the length, followed by the bytes.
84-
```text,ignore
85-
ptr --> <var int length> <data>
86-
```
69+
### Safety
8770

88-
# Memory Comparisons (Allocator)
71+
`ColdString` uses `unsafe` to implement its packed representation and pointer tagging. Usage of `unsafe` is narrowly scoped to where layout control is required, and each instance is documented with `// SAFETY: <invariant>`. To further ensure soundness, `ColdString` is written using [Rust's strict provenance API](https://doc.rust-lang.org/beta/std/ptr/index.html#strict-provenance), handles unaligned access internally, maintains explicit heap alignment guarantees, and is validated with property testing and MIRI.
72+
73+
## Benchmarks
74+
75+
### Memory Comparisons (Allocator)
8976

9077
Memory usage per string, measured by tracking the memory requested by the allocator:
9178

9279
![string_memory](https://github.com/user-attachments/assets/adf09756-9910-4618-a97f-b5ab91a2515a)
9380

94-
## Memory Comparison (System RSS)
81+
### Memory Comparison (System RSS)
9582

96-
RSS per insertion of various collections containing strings of random lengths 0..=N:
83+
Resident set size in bytes per insertion of various collections. Insertions are strings with random length 0..=N:
9784

98-
Vec | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
85+
Vec | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
9986
:--- | :---: | :---: | :---: | :---: | :---: |
10087
cold-string | 8.0 | 8.0 | 23.2 | 33.7 | 53.4
10188
compact_str | 24.0 | 24.0 | 24.0 | 34.6 | 60.6
@@ -105,17 +92,17 @@ smartstring | 24.0 | 24.0 | 24.0 | 40.4 | 65.4
10592
smol_str | 24.0 | 24.0 | 24.0 | 39.9 | 71.2
10693
std | 35.8 | 37.4 | 45.8 | 54.2 | 70.5
10794

108-
HashMap | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
95+
HashSet | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
10996
:--- | :---: | :---: | :---: | :---: | :---: |
110-
cold-string | 35.7 | 35.7 | 63.3 | 88.2 | 125.1
111-
compact_str | 102.8 | 102.8 | 102.8 | 123.7 | 175.5
112-
compact_string | 45.4 | 59.6 | 78.2 | 97.1 | 130.1
113-
smallstr | 102.8 | 102.8 | 129.7 | 155.0 | 191.6
114-
smartstring | 102.8 | 102.8 | 102.8 | 135.9 | 185.8
115-
smol_str | 102.8 | 102.8 | 102.8 | 134.8 | 196.6
116-
std | 112.8 | 123.9 | 143.2 | 161.8 | 195.3
117-
118-
B-Tree Set | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
97+
cold-string | 18.9 | 18.9 | 34.5 | 45.5 | 64.0
98+
compact_str | 52.4 | 52.4 | 52.4 | 62.2 | 88.9
99+
compact_string | 23.2 | 30.0 | 39.6 | 49.1 | 65.9
100+
smallstr | 52.4 | 52.4 | 66.5 | 78.6 | 96.9
101+
smartstring | 52.4 | 52.4 | 52.4 | 68.2 | 94.0
102+
smol_str | 52.4 | 52.4 | 52.4 | 68.3 | 99.4
103+
std | 56.8 | 61.9 | 72.2 | 81.7 | 98.5
104+
105+
BTreeSet | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
119106
:--- | :---: | :---: | :---: | :---: | :---: |
120107
cold-string | 10.1 | 18.9 | 49.3 | 79.1 | 117.2
121108
compact_str | 24.8 | 48.4 | 61.5 | 90.5 | 145.7
@@ -125,10 +112,8 @@ smartstring | 24.5 | 48.6 | 61.1 | 102.3 | 155.8
125112
smol_str | 25.0 | 48.3 | 61.6 | 100.7 | 166.7
126113
std | 35.8 | 70.4 | 102.9 | 128.9 | 165.5
127114

128-
**Note:** Columns represent string length (bytes/chars). Values represent average Resident Set Size (RSS) in bytes per string instance. Measurements taken with 10M iterations.
129-
130-
## Speed
131-
### Construction: Variable Length (0..=N) [ns/op]
115+
### Speed
116+
#### Construction: Variable Length (0..=N) [ns/op]
132117
Crate | 0..=4 | 0..=8 | 0..=16 | 0..=32 | 0..=64
133118
:--- | :---: | :---: | :---: | :---: | :---:
134119
cold-string | 10.0 | 9.2 | 25.3 | 30.0 | 37.2
@@ -139,7 +124,7 @@ smartstring | 14.8 | 15.1 | 15.0 | 26.9 | 4
139124
smol_str | 19.2 | 19.8 | 20.1 | 23.4 | 33.7
140125
std | 28.6 | 31.4 | 34.9 | 32.0 | 33.1
141126

142-
### Construction: Fixed Length (N..=N) [ns/op]
127+
#### Construction: Fixed Length (N..=N) [ns/op]
143128
Crate | 4..=4 | 8..=8 | 16..=16 | 32..=32 | 64..=64
144129
:--- | :---: | :---: | :---: | :---: | :---:
145130
cold-string | 6.5 | 4.2 | 34.2 | 34.3 | 36.2

0 commit comments

Comments
 (0)