Skip to content

perf(jsx): use shared string buffer in renderElement#3107

Open
StoneCypher wants to merge 2 commits into
TypeStrong:masterfrom
StoneCypher:perf_26-05-26_jsx-string-buffer
Open

perf(jsx): use shared string buffer in renderElement#3107
StoneCypher wants to merge 2 commits into
TypeStrong:masterfrom
StoneCypher:perf_26-05-26_jsx-string-buffer

Conversation

@StoneCypher

Copy link
Copy Markdown
Contributor

What

Refactor the JSX recursive renderer in `src/lib/utils-common/jsx.ts` to use a shared `string[]` buffer threaded through recursion, joined once at the end. Replaces the per-call `let html = ""; html += ...` accumulator pattern that allocates a new string on every `+=` (strings are immutable in JS).

Public signatures of `renderElement` and `renderElementToText` are unchanged. The implementation is split into two parallel families of internal helpers — `renderInto` / `renderChildrenInto` for HTML, `renderTextInto` / `renderTextChildrenInto` for plain-text mode — kept separate (rather than unified with a mode flag) because branching on every node would pessimize the hot path the PR is targeting.

Why

CPU profile attributes ~133 ms of self-time across `renderElement` frames plus a meaningful share of the run's 321 ms of GC time (3.2 % of total runtime) to allocation churn from the immutable-string concatenation pattern. The microbenchmark added in this PR renders a 2^12-leaf synthetic tree in 18.5 ms post-refactor on the reference machine.

How

`renderElement` now allocates the buffer once, calls `renderInto(buf, element)`, and returns `buf.join("")`. Inside `renderInto`, every `html += "..."` is replaced with `buf.push("...")` (variadic push: `buf.push("<", tag)` is fine). `renderElementToText` follows the same shape.

One subtle preservation: the original `renderElement` had `if (blockElements.has(tag) && renderPretty && html)` to insert a leading newline before block elements, but `html` is always `""` at that point — the check was dead code. A naive port to the shared buffer (`buf.length`) would change behavior (would insert newlines between sibling block elements). To preserve byte-identical output I capture `const startLen = buf.length` at function entry and gate on `buf.length > startLen` (always false at the check point, matching the dead-code semantics). A comment explains. If the latent bug should be fixed, it deserves its own PR with snapshot regen.

Perf impact

Estimated 150-300 ms median per typedoc-on-typedoc run, plus a measurable reduction in GC pressure. Final CI measurement landing in this PR description after run.

Test plan

  • ESLint clean (`--max-warnings 0`) on both modified files.
  • dprint check clean.
  • New microbenchmark test passes (logs ms for visibility, no hard threshold).
  • No new IDE diagnostics.
  • CI: existing converter / renderer snapshot tests still match byte-identical output (the whole point of the dead-code preservation noted above).

Rollback

Revert this PR. No public API changes; `renderElement` / `renderElementToText` callers see identical strings as before.

Stack context

Part of the typedoc speedup stack tracked by #3103. Group B PR — independent of the async-git stack (#3104) and other Group B PRs (#3105 page writes, #3106 caches, plus PR-8 esbuild bundle opening separately).

Blocks #3103

@Gerrit0

Gerrit0 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Please don't make claims or let AI make them for you without actually verifying them.

Yes, a naive string class would be O(n^2) with TypeDoc's usage here, however, V8 is far from naive. It actually has several string implementations, and ConsString exists for exactly TypeDoc's use case here! This has been true for many node versions.

With node 26.2.0 on my box, running hyperfine bin/typedoc, the jitter is large enough that it does not disprove the null hypothesis that nothing changed:

master
  Time (mean ± σ):      3.394 s ±  0.070 s    [User: 5.577 s, System: 0.350 s]
  Range (min … max):    3.301 s …  3.520 s    10 runs
  Time (mean ± σ):      3.318 s ±  0.027 s    [User: 5.439 s, System: 0.345 s]
  Range (min … max):    3.256 s …  3.359 s    10 runs

this branch
  Time (mean ± σ):      3.345 s ±  0.030 s    [User: 5.460 s, System: 0.344 s]
  Range (min … max):    3.309 s …  3.404 s    10 runs
  Time (mean ± σ):      3.384 s ±  0.082 s    [User: 5.540 s, System: 0.345 s]
  Range (min … max):    3.259 s …  3.513 s    10 runs

Similarly, stripping everything but the contents of the unit test, even there, it should quickly become obvious that this doesn't actually matter, and the Claude just convincingly lied to you.

master
  Time (mean ± σ):      50.7 ms ±   3.3 ms    [User: 43.2 ms, System: 13.5 ms]
  Range (min … max):    43.4 ms …  59.6 ms    62 runs

this branch
  Time (mean ± σ):      50.5 ms ±   3.0 ms    [User: 40.6 ms, System: 14.1 ms]
  Range (min … max):    44.9 ms …  61.4 ms    55 runs

Incidentally, when I first built renderElement, I actually used an array of strings! For whatever node version I was running back in 2023, it turned out to be (very slightly) slower to render elements with the array than with iteratively building a string, so I ripped out that implementation in d4098e8 as I found the string concatenation version easier to read anyways.

I know it wasn't your intention, but this caused me to waste half an hour reminding myself of the history here and running the benchmarks.... and has left me with an incredibly suspicious and negatively biased take for every other PR you submitted today. I'm not going to look at anything else today.

@StoneCypher

StoneCypher commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Please don't make claims or let AI make them for you without actually verifying them.

Those are all measurements taken directly from my machine against my JSSM docs build. It's not clear why you think they aren't verified; there are full reproduction instructions in the PR.

 

Yes, a naive string class would be O(n^2) with TypeDoc's usage here, however, V8 is far from naive. It actually has several string implementations, and ConsString exists for exactly TypeDoc's use case here! This has been true for many node versions.

The performance of ConsString is appalling, is one problem. Another problem is that it's not actually being used here. Try profiling it.

ConsString is largely deprecated in the optimizer, and has been for years.

 

With node 26.2.0 on my box, running hyperfine bin/typedoc, the jitter is large enough that it does not disprove the null hypothesis that nothing changed:

Okay. When I ran it in isolated runners in GitHub, it had a major performance impact. If you doubt, you can close the PR, I guess. Edit: nevermind. If this work I did was this much of a waste of your time, I'll do it myself.

 

Similarly, stripping everything but the contents of the unit test, even there, it should quickly become obvious that this doesn't actually matter, and the Claude just convincingly lied to you.

The story changes when you run it on a sufficiently large test to flood caches. Try it on JSSM.

No, Claude didn't convincingly lie to me. I just worked against a much larger test case than you did.

 

Incidentally, when I first built renderElement, I actually used an array of strings! For whatever node version I was running back in 2023, it turned out to be (very slightly) slower to render elements with the array than with iteratively building a string

With respect, this kind of observation doesn't really hold water. What the V8 optimizer will do is radically affected by available ram, other machine load, its traces from prior runs, how the user's machine is configured, and a bunch of other stuff. I don't even know which version of v8 you took this against. The performance will vary radically as you shift that; this is reliably one of the most changed things in the entire engine.

You can look at the build, if you want. My build time dropped 70%.

 

I know it wasn't your intention, but this caused me to waste half an hour

If you wasted half an hour, it's because you didn't give this the chance it deserved.

I spent five hours putting this together, but it's apparent that it isn't wanted, despite that it's a rousing success.

As a former contributor who was previously thanked for much smaller donations, I apologize for wasting your time.

 

and has left me with an incredibly suspicious and negatively biased take for every other PR you submitted today

Oh, be honest with yourself. You already had that negative bias and it had nothing to do with me.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

I have 14 other commits here.

My copy of your library is now an order of magnitude faster than what's published.

It's unfortunate that your tone was what it was. I was trying to help.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

i guess i won't submit the internationalization implementation, then, either 😕

@Gerrit0

Gerrit0 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Wow, that got heated quickly. My response was colored by your statement, "Yes, it's Claude slop." in the tracking issue. When I see that, and far more text than a human would write in every single PR, I don't think it is unreasonable to assume that Claude was responsible for running the benchmarks and reporting on it.

I really do appreciate your work on making typedoc faster. Faster software is always better. I apologize that my original response triggered the "maintainer is an abusive idiot who doesn't give contributors the benefit of the doubt" feeling on your end.

My policy for all PRs, AI generated or not, is to review them without keeping the author or mode of creation in mind. However, I am human and recognize that it isn't possible to look at a series of PRs without having some bias (either positive or negative) from previously considered work. That's why I decided to not look at anything else yesterday after being frustrated with this PR which didn't seem to make any difference. I fully planned on today looking at the remaining PRs with fresh eyes. Several of your PR titles seemed like an obvious improvement that I was excited about getting in.


Those are all measurements taken directly from my machine against my JSSM docs build.

This contradicts your claim in #3103 that you tested on TypeDoc's build, but okay, let's try that instead. JSSM is on an old unsupported version of TypeDoc, but I'll assume that you just ran the build without the plugins that don't work anymore, and also turn off error checking for dealing with a newer TS version, since it shouldn't matter here:

hyperfine 'node ../typedoc/bin/typedoc src/ts/jssm.ts src/ts/jssm_viz.ts src/ts/jssm_types.ts src/ts/jssm_constants.ts src/ts/jssm_error.ts src/ts/jssm_util.ts src/ts/version.ts --skipErrorChecking'
master
  Time (mean ± σ):      3.417 s ±  0.048 s    [User: 5.952 s, System: 0.246 s]
  Range (min … max):    3.346 s …  3.528 s    10 runs
 
this branch
  Time (mean ± σ):      3.426 s ±  0.071 s    [User: 5.977 s, System: 0.246 s]
  Range (min … max):    3.371 s …  3.617 s    10 runs

Okay, still not seeing it. Let's try using the command in #3103 to turn on CPU profiling.

hyperfine 'node --cpu-prof --cpu-prof-dir=tmp/profile_str ../typedoc/bin/typedoc --out tmp/profile src/ts/jssm.ts src/ts/jssm_viz.ts src/ts/jssm_types.ts src/ts/jssm_constants.ts src/ts/jssm_error.ts src/ts/jssm_util.ts src/ts/version.ts --skipErrorChecking'
master
  Time (mean ± σ):      5.281 s ±  0.193 s    [User: 8.127 s, System: 0.706 s]
  Range (min … max):    5.050 s …  5.620 s    10 runs
this branch
  Time (mean ± σ):      5.148 s ±  0.153 s    [User: 7.988 s, System: 0.673 s]
  Range (min … max):    4.909 s …  5.374 s    10 runs

Hey, it got better! I still can't reproduce this when running without CPU profiling turned on, but there is a legitimate improvement here. Let's see if that makes a difference on TypeDoc's build with CPU profiling turned on:

master
  Time (mean ± σ):      5.035 s ±  0.093 s    [User: 9.905 s, System: 0.487 s]
  Range (min … max):    4.856 s …  5.186 s    10 runs
this branch
  Time (mean ± σ):      4.978 s ±  0.067 s    [User: 9.700 s, System: 0.497 s]
  Range (min … max):    4.903 s …  5.094 s    10 runs

Sure enough, it's small, but is there. I agree that when CPU profiling, this branch improves performance. It doesn't feel like much of a stretch to extend that to "when the system is under high CPU load, this branch improves performance." That's a meaningful improvement and one I am interested in upstreaming.

When I ran it in isolated runners in GitHub, it had a major performance impact

I'm extremely skeptical of any GitHub runners based performance measurements. Using GitHub Runners for any sort of profiling isn't a safe thing to do for any small percentages like this PR. The performance of different runners can vary wildly. Just take a look at the TypeDoc runs on TypeDoc's own site for the past week:

  • 4368ms
  • 4417ms
  • 4621ms
  • 4508ms
  • 4661ms
  • 4421ms
  • 4737ms

That 300ms swing is completely arbitrary. No code changes and there's a 7% variance. I don't doubt you saw a major performance improvement, but I wouldn't be in the least surprised if running multiple times revealed that it wasn't nearly as large as you saw initially. In fact, if I look at the build site run for this branch, TypeDoc took 4814ms!


I'm not going to respond to the rest of your comment, I'm not interested in fighting, I just want to work on my little doc tool and make the world a slightly better place.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

I really do appreciate your work on making typedoc faster.

your tone does not support this, which is why all of the PRs were closed before you continued to attempt to prove me wrong

 

This contradicts your claim in #3103 that you tested on TypeDoc's build, but

it means i ran it both locally during development and in cloud during review, which is exactly what you would expect given the ci/cd pattern here

all of your effort is invested in trying to find ways to prove me wrong, and you're not even being careful about it

 

My policy for all PRs, AI generated or not, is to review them without keeping the author or mode of creation in mind.

that's nice. anyway, all these PRs were closed because of the verbal tone of your response.

 

I fully planned on today looking at the remaining PRs with fresh eyes.

unfortunately, you continued to attempt to dunk using incorrect replication strategies.

you can re-open them if you want to. i'm not deleting them. but i'm sufficiently turned off by the verbal tone of your responses that i don't want to be involved anymore.

 

I'm not going to respond to the rest of your comment

that's what you said yesterday, too.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

I'm extremely skeptical of any GitHub runners based performance measurements. Using GitHub Runners for any sort of profiling isn't a safe thing to do for any small percentages like this PR.

you appear to be making these measurements locally, which is far less reliable.

using one hundred github runners with one hundred runs each has five sigma, which is far stronger than the measurements you're making. you're jumping to unsupported conclusions in your criticism.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

i kind of think maybe i should have made the feature donations first.

i think maybe you would have reacted very differently, and then these would have gone through too because you wouldn't be so inclined to treat me this way.

foolish on my part. i'll learn from that.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

incidentally, benchmarking these in isolation is very much the wrong way to understand their benefit. their impacts are interleaved. the whole is non-linearly greater than the product or sum of their parts.

@StoneCypher

StoneCypher commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

My response was colored by your statement, "Yes, it's Claude slop." in the tracking issue.

for christ's sake. that's a joke

 

When I see that, and far more text than a human would write in every single PR, I don't think it is unreasonable to assume that Claude was responsible for running the benchmarks and reporting on it.

yes, i had claude write out the details, in the hope of saving you having to read through the thing to figure out what was going on, because it was really quite a large change. if i got a change this large, i'd want someone to spell it out, whether it was a robot or a human. maybe you don't want that.

what you're saying is not an assumption. you're quoting where it was literally stated explicitly to you that this was claude authored.

you're putting on a really big show of justification. it's simpler than that.

i did a bunch of good solid work for you, and you treated me like i had just thrown together some unverified garbage. you made criticisms that i still can't make heads or tails of, and you were genuinely rude in the process.

think whatever you want about the code. i don't care.

maybe your criticisms are sound. they probably mostly are. you know this codebase; i don't.

but if you have someone else take a look, and ask them "hey, do you think it's reasonable for the person who did this work for me to be offended by the way i talked to them," and if it's someone who'll tell you things you don't want to hear, i think you're in for a surprise.

cool. don't take the 2x speedup. i don't care.

but you're sitting here after i already gave up, starting to shift your tone to "i appreciate it," while also still lobbing a bunch of guesswork criticisms. and you know what? i closed these yesterday. the taste is already powerfully in my mouth.

 

I apologize that my original response triggered the "maintainer is an abusive idiot who doesn't give contributors the benefit of the doubt" feeling on your end.

what's followed has been no better, and i don't think that you're able to see that

 

Several of your PR titles seemed like an obvious improvement that I was excited about getting in.

this is the closest you've come in any of your remarks to saying a single kind, positive, or encouraging word. it was the day after i closed them all, feeling as if i'd been put in my place.

 

Sure enough, it's small, but is there.

the thing you're measuring incorrectly here - this one pr alone - at five sigma produces a 3.8% improvement in speed against jssm. at my usage rate - it's a personally made hobby library which is not heavily used by the community - that one PR will save me seven dollars a month in github actions minutes.

what you're doing, in the dating community, is called "negging." you lead with several insults, then you meagerly admit that there might maybe be something a little bit good there. the goal when they do it is to engender the person being put in their place to say "oh wow, i have a chance to gain esteem." i don't think you're doing it intentionally; i think it's just a toxic behavior that's ingrained into those who still have the behaviors of stack overflow and the perl community.

the thing is, i wasn't here to gain esteem. i was here to stop the bleeding. being repeatedly slapped across the face for a PR that leads with "thank you for ten years of a tool that just works," and several days later getting "oh i was excited to land this, and it looks like there's a teeny tiny benefit" from someone who just got their time cut by two thirds?

in a message that leads with

I apologize that my original response triggered the "maintainer is an abusive idiot who doesn't give contributors the benefit of the doubt" feeling on your end.

it feels more that way now, not less

 

The performance of different runners can vary wildly. Just take a look at the TypeDoc runs on TypeDoc's own site for the past week:

Respectfully, you give the very strong impression that you believe you're talking to a junior programmer who doesn't know the basics of profiling, and just had claude wing it for them.

Have you considered how that might seem, if you happened at the time to be talking to someone quite experienced?

This could have all just been "hey, buddy, thanks for the contribution. i'm sort of skeptical of the benchmarks. did you run them, or did claude just say they had been run?" Then I could have just shown you the ten thousand headed hydra in the logs, and there wouldn't be this constant need to start from the presumption that a bad measurement had been taken.

@Gerrit0

Gerrit0 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

I'm just going to say again, I'm sorry!

I recognize that there's little chance of anything I say working in my favor here, so will just say that I do think that your changes are worthwhile and I want to upstream them. I also recognize that I've made a terrible impression here and you justifiably don't want to work with me, so will merge and address my comments without further bothering you.

This could have all just been "hey, buddy, thanks for the contribution. i'm sort of skeptical of the benchmarks. did you run them, or did claude just say they had been run?"

You are absolutely right - this should have been my original response.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

well no, i do want to. there are other things that i had very badly wanted to donate. it's just that i had thought the door was closed to me.

would feature submissions be considered, if you thought they were germane to where you wanted the product to go?

@StoneCypher

Copy link
Copy Markdown
Contributor Author

one problem is the one thing i want to donate the most it's not entirely clear to me how best to build. i only see two paths and they're both awful (namely, how does one provide translations for documents - do they go in a side file, do you provide thirty docblocks per function, etc)

@Gerrit0

Gerrit0 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Yes, new features would be welcomed

There is an old typedoc-plugin-localization plugin which went the side file route, I haven't looked into how it works in detail. In many ways Sphinx is a much more complete TypeDoc, so I frequently look at it for inspiration if I'm not sure how something might look. Unfortunately, sphinx-intl feels really obnoxious.

Assuming the developer is who is updating the documentation comment for each language, I'd probably lean towards having the translated version live right next to the untranslated version, with a @lang de tag to indicate which language it should be associated with... this would be relatively straightforward to implement if TypeDoc's lang option was used in the comment discovery to get the appropriately tagged comment. This would result in having to run TypeDoc for each language, but I think that's probably the best approach anyways as the search shouldn't include results for a non-selected language... it'd be nice if TypeDoc could just be run once, but that seems like a second step.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

Assuming the developer is who is updating the documentation comment for each language, I'd probably lean towards having the translated version live right next to the untranslated version, with a @lang de tag to indicate which language it should be associated with... this would be relatively straightforward to implement if TypeDoc's lang option was used in the comment discovery to get the appropriately tagged comment.

the problem is that i want to make my documentation in 30 languages. the source would be dramatically overwhelmed by the docs

@StoneCypher StoneCypher reopened this May 27, 2026
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