siimon / prom-client

Prometheus client for node.js
Apache License 2.0
3.15k stars 378 forks source link

perf: improve getMetricAsString for long strings + less allocations #587

Open lassic opened 1 year ago

lassic commented 1 year ago

Do all replacements for escaping in a single pass (less large string copies due to chained replace calls).

@shappir you improvement was really good, I was trying to also address allocations here (chained replace), would actually love your take here as well, since it's possible the best solution is some combination.

image image
lassic commented 1 year ago

exciting! mind adding a changelog entry as well?

Sure, I'll have a look at the changelog.

lassic commented 1 year ago

Here's an example of a benchmark vs. prom-client@v15.0.0-1 which shows that in some cases, in terms of ops/sec prom-client@v15.0.0-1 wins out. That's why I would love @shappir to have a look as well since our PRs are not compatible.

image image
SimenB commented 1 year ago

Cool, let's hold off a tiny bit and see if @shappir is able to provide some feedback here 🙂

shappir commented 1 year ago

Thank you - reviewing

lassic commented 1 year ago

Thank you - reviewing

Thank you. I'll note that I got to this because we saw getMetricsAsString participating in many "blocked event loop" stacks, but not only due to CPU, but (what seems like) causing young space to run out more often and cause GC. I was trying to reduce (large) allocations of strings due to replace chains, and was hoping also a single pass is more efficient. The single pass theory seems only partly true, it's very possible that v8 optimizes the native replace function better than passing a JS replace func (makes sense).

SimenB commented 1 year ago

Have you had time to review this yet, @shappir? 🙂

shappir commented 1 year ago

Sorry for the delay. Reviewing right now.

shappir commented 1 year ago

I measured just the string escape implementation outside the context of prom-client (looped 100,000 over existing and new escape implementations). When replacing '\' and '\n' the existing implementation consistently came out as 2.5 as fast as new implementation. When also replacing '"' it came out as 1.5 as fast. This is because the new implementation has the same speed in both cases while the existing implementation is slower when replacing more chars.

My conclusion is that using a single pass will be faster when replacing 4 or more chars, but not for less. As a result, it seems not to be appropriate for this use-case.

lassic commented 1 year ago

I measured just the string escape implementation outside the context of prom-client (looped 100,000 over existing and new escape implementations). When replacing '\' and '\n' the existing implementation consistently came out as 2.5 as fast as new implementation. When also replacing '"' it came out as 1.5 as fast. This is because the new implementation has the same speed in both cases while the existing implementation is slower when replacing more chars.

My conclusion is that using a single pass will be faster when replacing 4 or more chars, but not for less. As a result, it seems not to be appropriate for this use-case.

Thanks @shappir I agree, see in my original comments and benchmarks, it's quite clear that the new implementation is better for many replacements (big strings) and slower for very short ones. In addition, I also mentioned the memory allocations benefits of using less replace chains (which create new strings) and we've seen this have an effect on GC and the eventloop. So I'm coming back to my comment of "so perhaps there's a place for a different strategy for different inputs, but I didn't want to get into that complexity here." - perhaps the gains for large strings are significant enough to justify this? choosing a different replacement strategy according to string length?

SimenB commented 1 year ago

perhaps the gains for large strings are significant enough to justify this? choosing a different replacement strategy according to string length?

That seems reasonable to me at least 🙂

shappir commented 1 year ago

Perhaps I wasn't clear enough or I'm not understanding you:

Based on my tests (using your implementation but external to prom-client) the performance ratios I see are consistent across strings of different lengths. I tested strings of length ~1,000, ~10,000 and ~100,000 and got the same ratios. For me, in all these cases the existing escape function was 1.5 to 2.5 faster.

The new escape function is faster only when the length of the replace chain gets longer. But since currently the length of the chain is capped at 3, this has no impact.

But on this, my current recommendation is to not merge this PR.

Note: I did not measure GC impact specifically. The existing method may indeed create more garbage which could impact execution speed, even externally to prom-client itself.

lassic commented 1 year ago

@shappir I understand, and yes, I misunderstood your initial comment, thanks for the clarification. However, I'm not sure I understand then why the project benchmarks are different than the ones you are running. Is it because you are running more dedicated benchmarks on this area with more loops, and the original benchmarks have a larger deviation? Still, it's pretty consistent for me, so I wonder what's the difference. Anyway, I'll keep looking at this, but unless someone thinks otherwise I guess it's not overwhelming enough for me to push this right now.

@SimenB Let me know what you want to do. Feel free to close the PR or whatever you think.

shappir commented 1 year ago

Final note: interestingly this person conducted a similar comparison, and the results match my findings https://pablotron.org/2022/03/09/fastest-js-html-escape/#the-winner-h9 (I also checked using replaceAll instead of replace and it made no noticeable difference. Apparently it's mostly beneficial when the RegExp is not known in advance.)