Skip to content

Avoid unnecessary AttributedString copies on iOS - #59018

Open
dheerajkoppu wants to merge 1 commit into
react:mainfrom
dheerajkoppu:ios-text-measure-copies
Open

dheerajkoppu wants to merge 1 commit into
react:mainfrom
dheerajkoppu:ios-text-measure-copies

Conversation

@dheerajkoppu

@dheerajkoppu dheerajkoppu commented Oct 12, 2026 •

Copy link
Copy Markdown

Summary:

Measuring <Text> on iOS makes a bunch of unnecessary copies of AttributedString.

There are a few places where this happens:

  • ParagraphShadowNode copies a local string into AttributedStringBox even though the local is about to be destroyed.
  • TextLayoutManager::measure uses auto instead of a reference, which makes another copy.
  • ensurePlaceholderIfEmpty_DO_NOT_USE copies the string even when it's not empty.
  • The RCTTextLayoutManager methods take the string by value, which also copies it.

This PR avoids those extra copies by using std::move, passing references where possible, and only creating a placeholder when the string is actually empty. Nothing changes in how text is rendered.

With these changes, a cache hit goes from 5 copies to 2, and a cache miss goes from 11 to 6. Draw, hit-testing, and frame updates go from 2 copies to 0. The remaining copies mostly come from cache keys, which I left alone.

It's a pretty small cleanup, but it removes some unnecessary work.

I also ran a quick benchmark on an M4 Pro using clang -O2, taking the median of 7 runs:

Fragments Deep copy Box creation (before → after) measure() entry (before → after)
1 41 ns 87 → 52 ns 77 → 1 ns
3 76 ns 160 → 89 ns 150 → 1 ns
10 211 ns 436 → 227 ns 427 → 1 ns
Benchmark code
#include <react/renderer/attributedstring/AttributedString.h>
#include <react/renderer/attributedstring/AttributedStringBox.h>
#include <react/renderer/attributedstring/PlaceholderAttributedString.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <memory>
#include <optional>
#include <vector>

using namespace facebook::react;

static AttributedString makeString(int fragmentCount) {
  AttributedString attributedString;
  auto owner = std::make_shared<int>(0);
  for (int i = 0; i < fragmentCount; i++) {
    AttributedString::Fragment fragment;
    fragment.string = "This is a typical row label in a list";
    fragment.textAttributes.fontSize = 16;
    fragment.parentShadowView.tag = i + 1;
    // Aliasing shared_ptrs: real refcount traffic without constructing Props/EventEmitter/State.
    fragment.parentShadowView.props = std::shared_ptr<const Props>(owner, nullptr);
    fragment.parentShadowView.eventEmitter = std::shared_ptr<EventEmitter>(owner, nullptr);
    fragment.parentShadowView.state = std::shared_ptr<const State>(owner, nullptr);
    attributedString.appendFragment(std::move(fragment));
  }
  return attributedString;
}

template <typename F>
static double nsPerIteration(F&& body, int iterations) {
  std::vector<double> samples;
  for (int run = 0; run < 7; run++) {
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < iterations; i++) body();
    auto end = std::chrono::steady_clock::now();
    samples.push_back(std::chrono::duration<double, std::nano>(end - start).count() / iterations);
  }
  std::sort(samples.begin(), samples.end());
  return samples[3];
}

#define ESCAPE(x) asm volatile("" : : "r"(&(x)) : "memory")

int main() {
  const int iterations = 200000;
  std::printf("| Fragments | Deep copy | Box from lvalue (before) | Box from rvalue (after) | measure() entry before | measure() entry after |\n");
  std::printf("|---|---|---|---|---|---|\n");
  for (int n : {1, 3, 10}) {
    const auto source = makeString(n);
    AttributedStringBox box{source};

    double copy = nsPerIteration([&] { AttributedString c = source; ESCAPE(c); }, iterations);
    // The three ParagraphShadowNode sites: a dying local handed to the box.
    double boxBefore = nsPerIteration([&] { auto local = source; AttributedStringBox b{local}; ESCAPE(b); }, iterations);
    double boxAfter = nsPerIteration([&] { auto local = source; AttributedStringBox b{std::move(local)}; ESCAPE(b); }, iterations);
    // The iOS TextLayoutManager::measure entry, before and after.
    double entryBefore = nsPerIteration([&] {
      auto original = box.getValue();
      auto attributedString = ensurePlaceholderIfEmpty_DO_NOT_USE(original);
      ESCAPE(attributedString);
    }, iterations);
    double entryAfter = nsPerIteration([&] {
      const auto& original = box.getValue();
      auto placeholder = original.isEmpty() ? std::optional<AttributedString>{ensurePlaceholderIfEmpty_DO_NOT_USE(original)} : std::nullopt;
      const auto& attributedString = placeholder ? *placeholder : original;
      ESCAPE(attributedString);
    }, iterations);
    std::printf("| %d | %.0f ns | %.0f ns | %.0f ns | %.0f ns | %.0f ns |\n", n, copy, boxBefore, boxAfter, entryBefore, entryAfter);
  }
}

Changelog:

[IOS] [CHANGED] - Pass AttributedString by const reference in RCTTextLayoutManager and add a move constructor for AttributedStringBox.

Test Plan:

  • Added testValueMoveConstructor to AttributedStringBoxTest.
  • yarn fantom Text: all 223 tests pass.
  • RNTester builds and runs on the iPhone 18 Pro simulator, and all 172 RNTesterUnitTests pass.
  • yarn format-check-cpp passes.
  • Updated API snapshots.
  • Haven't benchmarked this on a physical device yet.

iOS text measurement deep-copied the AttributedString several times per
measure, even on a cache hit. Bind references where the code only reads,
move dying locals into the box, add an AttributedString&& box constructor,
build the empty-string placeholder only when needed, and take
const AttributedString& in the RCTTextLayoutManager methods.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 12, 2026
@dheerajkoppu dheerajkoppu changed the title Avoid copying AttributedString on the iOS text measure path Avoid unnecessary AttributedString copies on iOS Oct 12, 2026
@dheerajkoppu
dheerajkoppu marked this pull request as ready for review October 13, 2026 04:07
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 13, 2026

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant