Skip to content

Tags allocates once per tag and once per element #1428

Description

@JSKitty

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:

  1. Is the memory worth a breaking change to Tag's accessors, or would you rather keep the current API?
  2. If yes, what should replace as_slice() -> &[String]?
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions