Summary
Tags allocates once per tag and once per element inside every tag. On a typical threaded note that is 43 allocations and about 2.5x the memory the tag content actually needs.
Raising this as an issue rather than a PR because any fix changes Tag's public accessors, and that is your call to make before code exists.
The shape
pub struct Tags { list: Vec<Tag> }
pub struct Tag { buf: Vec<String> }
Three levels of indirection, nothing inline. A tag like ["t", "bitcoin"] is a heap Vec<String> holding two heap Strings.
Measured
Apple M4, allocation count and live heap for the Tags structure alone:
| event shape |
tags |
elements |
allocations |
heap held |
tag content |
| plain note |
0 |
0 |
0 |
0 B |
0 B |
| 1 hashtag |
1 |
2 |
4 |
79 B |
7 B |
| reply + 1 mention |
2 |
6 |
9 |
347 B |
155 B |
| reply + 3 mentions |
4 |
10 |
15 |
621 B |
285 B |
| thread, 12 tags |
12 |
30 |
43 |
1689 B |
681 B |
| heavy, 30 tags |
30 |
80 |
111 |
4550 B |
1910 B |
The count is always 1 + tags + elements: one Vec<Tag>, one Vec<String> per tag, one String per element.
Each String carries a 24-byte header plus its own malloc block and rounding, so a hashtag whose real content is 7 bytes occupies 79. At realistic sizes it settles around 2.4x to 2.5x.
Holding a synced timeline:
|
heap held |
tag content |
overhead |
| 10,000 replies (4 tags each) |
6.45 MB |
2.85 MB |
2.3x |
| 10,000 thread posts (12 tags each) |
17.13 MB |
6.81 MB |
2.5x |
Tags::clone on a 12-tag event is 1254 ns, which is 43 mallocs. For context, after #1425 that is more than twice what parsing the whole relay message wrapper costs.
Possible direction
One buffer holding every tag's bytes concatenated, plus a small offset table, rather than a String per element. Tags becomes roughly two allocations regardless of tag count, and the per-element header disappears. That should take 43 to 2 and the 2.5x down to near 1x.
Other options are smaller: Box<str> in place of String saves 8 bytes per element but no allocations, and inline storage only helps elements under about 22 bytes, which excludes the 64-character hex ids that dominate real tags.
Why this needs discussing first
Tag::as_slice() returns &[String]. A flattened representation cannot produce that type, so the accessor has to change shape, and that ripples into anything iterating tags. There are reasonable answers, an iterator of &str, or an index-based accessor, but which one you want is an API decision rather than an implementation detail.
Questions worth settling before anyone writes code:
- Is the memory worth a breaking change to
Tag's accessors, or would you rather keep the current API?
- If yes, what should replace
as_slice() -> &[String]?
- Does this belong in the current cycle or after v0.45?
Happy to do the work if you want it. I would rather agree the API first than send a diff that assumes an answer.
Numbers came from a small harness using a tracking global allocator. Glad to share it if useful.
Summary
Tagsallocates once per tag and once per element inside every tag. On a typical threaded note that is 43 allocations and about 2.5x the memory the tag content actually needs.Raising this as an issue rather than a PR because any fix changes
Tag's public accessors, and that is your call to make before code exists.The shape
Three levels of indirection, nothing inline. A tag like
["t", "bitcoin"]is a heapVec<String>holding two heapStrings.Measured
Apple M4, allocation count and live heap for the
Tagsstructure alone:The count is always
1 + tags + elements: oneVec<Tag>, oneVec<String>per tag, oneStringper element.Each
Stringcarries a 24-byte header plus its own malloc block and rounding, so a hashtag whose real content is 7 bytes occupies 79. At realistic sizes it settles around 2.4x to 2.5x.Holding a synced timeline:
Tags::cloneon a 12-tag event is 1254 ns, which is 43 mallocs. For context, after #1425 that is more than twice what parsing the whole relay message wrapper costs.Possible direction
One buffer holding every tag's bytes concatenated, plus a small offset table, rather than a
Stringper element.Tagsbecomes roughly two allocations regardless of tag count, and the per-element header disappears. That should take 43 to 2 and the 2.5x down to near 1x.Other options are smaller:
Box<str>in place ofStringsaves 8 bytes per element but no allocations, and inline storage only helps elements under about 22 bytes, which excludes the 64-character hex ids that dominate real tags.Why this needs discussing first
Tag::as_slice()returns&[String]. A flattened representation cannot produce that type, so the accessor has to change shape, and that ripples into anything iterating tags. There are reasonable answers, an iterator of&str, or an index-based accessor, but which one you want is an API decision rather than an implementation detail.Questions worth settling before anyone writes code:
Tag's accessors, or would you rather keep the current API?as_slice() -> &[String]?Happy to do the work if you want it. I would rather agree the API first than send a diff that assumes an answer.
Numbers came from a small harness using a tracking global allocator. Glad to share it if useful.