Get rid of TraceId/SpanId wrappers, and have the SpanContext hold the ids#1374
Get rid of TraceId/SpanId wrappers, and have the SpanContext hold the ids#1374jkwatson wants to merge 12 commits intoopen-telemetry:masterfrom
Conversation
Codecov Report
@@ Coverage Diff @@
## master #1374 +/- ##
============================================
- Coverage 86.56% 86.47% -0.09%
- Complexity 1367 1373 +6
============================================
Files 162 162
Lines 5231 5272 +41
Branches 490 504 +14
============================================
+ Hits 4528 4559 +31
- Misses 524 531 +7
- Partials 179 182 +3
Continue to review full report at Codecov.
|
|
I like the approach - happy to take a detailed look if everyone thinks so too. |
|
An interesting take. I'm curious about performance, of course, and also about how expensive would be be now to check for invalid values ( // HttpTraceContext.inject()
if (span == null || !span.getContext().isValid()) {
return;
}Aside that, looks a good, correct approach. |
|
If we want to go this way, this PR still needs a bunch of cleanup. I'd probably defer working on performance optimizations, but I'd like to get rid of a bunch of now-unused stuff, and get the javadoc up-to-date before merging. |
|
on the SIG call, it was recommended to use CharSequence, rather than String. I'll amend this draft with that. |
|
ok, converted over to CharSequence for the ids. I ran the So, as another experiment, I tried having the random ids generator return private wrappers for the Longs that lazily evaluated the CharSequence. This recovered most of the lost allocation performance, but it's still slightly worse. So, I guess the tradeoff here is whether we think it's worth the tradeoff, simply to get rid of the wrapper objects from the API. @trask and @anuraaga thoughts, since you were the original requestors of #1314 ? |
anuraaga
left a comment
There was a problem hiding this comment.
Thanks for the pounding on this, it's fun to think about these issues :)
If possible it'd be nice to copy in the JMH results to see the changes for each iteration.
Also, what JDK are you using to run? The problem with strings is they perform significantly differently in Java 8 or 9+, so we need to be careful. If we run benchs on both it's ideal, but otherwise I'd target 11 given how old 8 is by now.
| BigendianEncoding.longToByteArray(id, dest, destOffset); | ||
| /** Converts the long id value into a base-16 representation of it. */ | ||
| public static CharSequence fromLong(long id) { | ||
| char[] chars = new char[BASE16_SIZE]; |
There was a problem hiding this comment.
I think you can get a significant improvement by using a recycled buffer, maybe closing the gap you're seeing. Here's an example
It's a bit confusing since it used to be sized to just fit an ID but also got generalized to use when generating trace headers, which would probably be nice to do here too in a separate PR :)
There was a problem hiding this comment.
definitely a good idea!
I wouldn't entirely drop Java 8, even if it's performance-wise. Jason Plumb showed a link in the last Java SIG call about how Java 8 still dominates the market, percent-wise, these days. |
The most common case for random values is usage of the API without the SDK, i.e. a theoretically no-op scenario (expected to be cheap). With |
FYI, Java 11 is much, much more allocation-efficient than java 8 with the benchmark I've been running, although I'm sure I don't know why that would be! |
I can jump back to that commit hash and check. I'll do that. |
The String allocation performance was basically identical to what's in this PR, before @anuraaga 's recommendation to re-use the char buffers. So, currently we're significantly better than the String performance, but that hadn't really had any optimization done on it, either. |
|
For those interested in playing along at home, I have a gist with the benchmark output over time (in reverse chronological order, and roughly labeled with the changes that were made): https://gist.github.com/jkwatson/5ee98fd7b140364f0435217148569c09 |
|
So, we don't really have a full end-to-end benchmark from extraction to OTLP, and the way the project is structured, it would require a new module that depended on a bunch of stuff. I think this would be a valuable addition, though. I did, however, run the extractor and injector benchmarks that we do have, and the API no-op benchmarks as well. The extractors are, as you would expect, in general much more efficient from an allocation perspective. The injectors are pretty much the same, before and after (this looks to be because we weren't really allocating anything related to traceid/spanid in the inject process, but just copying chars around between arrays). However the no-op API is quite a bit worse, because now the random DefaultSpan that is created has to create the String representations of the longs. This could be mitigated by wrapping the longs up like I did in the SDK id generator, but then we're basically back where we started, and the agent codebase would need to deal with those wrapper classes in the API. |
|
Thanks for the investigations - I didn't think about the no-op use case. How does it work? I figured no-op would just return invalid spans and not generate any randoms - it's no-op :) |
|
I'm not 100% sure why we generate IDs for the default spans. @carlosalberto do you have the context for that decision? |
|
I keep on remembering this PR and waiting until #1386 to add more comments, but instead of reremembering all the time, let me go ahead and add them with optimism towards that PR If the no-op case is satisfied by disabling the random IDs, how do we feel about this? My opinion is to go for it
Hope this helps, just wanted to present my thoughts. |
True, but it's a wrapper that has been hyper-optimized by the JVM and JIT over the last 20 years. :) I also think that this is a good change. I'm also pretty sure that there are still optimizations that can be done, such as casting to Strings when the implementation is a String. I think binary trace propagation is still an unknown, since we don't know how the ids need to be serialized at this point. long->bytes might be equivalent to String->bytes for those cases; it's just an unknown right now. Anyway, yes, I also think this is a good move, if we can get #1386 landed. |
bogdandrutu
left a comment
There was a problem hiding this comment.
With the proposed changes to the SpanContext API we can still store longs but inside the SpanContext. We will have a small problem with Sampler which accepts a TraceId I don't know if we can do better than passing a CharSequnce there but will think.
| * @since 0.1.0 | ||
| */ | ||
| public abstract TraceId getTraceId(); | ||
| public abstract CharSequence getTraceId(); |
There was a problem hiding this comment.
Would name this getBase16TraceId.
I would also add helpers for propagators copyBytesTraceId copyBase16TraceId similar to current methods on TraceId.
Same applies to all the components.
| @@ -75,7 +75,7 @@ public static SpanContext create( | |||
| * @since 0.1.0 | |||
| */ | |||
| public static SpanContext createFromRemoteParent( | |||
There was a problem hiding this comment.
To avoid allocations it is probably better to have the ctor that allows to pass a charsequnce and a position from where it starts.
CharSequnce traceIdBuf, int traceIdOffset for the current ctor all the offsets will be 0 but for w3c we can avoid calling substring and internally we can still store longs if we want or we can for the moment do the substring.
Similar ctor can be offered for bytes Buffer (not that class probably a byte array as a buffer).
|
The thing that I don't like about using CharSequnce is the fact that we don't do the copy of the sequence and it is not guaranteed by the jvm that it is immutable, so can be backed by a mutable storage and all our guarantees are gone. So we either go with full String as @anuraaga proposed or we do what I proposed which is we keep the internal storage as longs but inside the SpanContext |
|
I would check if CharSequence cs; cs.subSequence(0,16).toString() is a no-op if the initial cs is a string of 16 length, if that is the case my proposed API would work the same for both backing the IDs by string or by longs, then we can evaluate which one we want now. |
I'm slightly in favor of keeping the ids as longs inside |
|
I'd still recommend String - String is almost always used, for propagation, and often used multiple times, e.g., logs correlation. |
|
One thing discussed on Friday was the possibility of exposing the inputs in multiple, immutable ways, perhaps both |
Does anyone use longs? :P I guess this is more having accessors, or helpers, that return byte arrays in addition to string. This is probably mostly a naming issue. |
|
I'm going to take this PR and do some more massaging on it. I'm hoping I can just create the barest minimal API that is currently required to implement what we currently have for propagation, etc. Then, when we add binary propagation, we can add additional API methods to facilitate making that work well. I'll focus first on the API surface, and worry a bit less about the SDK internals in the first pass. |
|
Just got to reviewing this, and understand now why you like this 😄. I think maybe only store the CharSequences in SpanContext (and not additionally the longs). We can use CharSequence implementations backed by one/two longs for the random generated span/trace id. And since those CharSequence implementations will only be used for random generated ids, I think they can be simple, e.g. don't need to worry about endian stuff, something like below. And the OTLP exporter can do very efficient (bitwise math / zero allocation) conversion from CharSequence to the one/two longs that it wants during export. And then we could simplify SpanContext, only exposing:
|
Yes. That was mostly an experiment in "what if" to store both. In the end, the advantage of this approach is to hide all the details. 2 questions: Do you prefer exposing CharSequence or String for the hex representation? |
|
Sorry, pondering this whole thing still...... Two longs are a very efficient way to store a 32-digit hex string (16 bytes vs 64 bytes), and we can still expose a very efficient (zero-allocation) CharSequence hex representation on top of them. Strings are also wrapper objects (with an extra layer: String -> pointer to char array -> char array). What about keeping TraceId/SpanId and having them implement CharSequence? |
|
One of the advantages of eliminating TraceId/SpanId is the auto-instrumentation bridge. But we can get the same performance benefit by turning TraceId/SpanId into interfaces: open-telemetry/opentelemetry-java-instrumentation#975. There is still some bridge complexity that would go away if we eliminate TraceId/SpanId, but I don't think that's too compelling (@iNikem probably disagrees 😄). |
|
My ideal is strongly typed without bridge :) |
|
I think we can't really avoid a So even if we stick with having longs, I really don't see a way around a lazy inited The above basically sums up that I think if the ID is returned as As for the wrappers, the spec now clearly defines that the IDs need to be accessible as hex or binary. So I think having these accessors directly on the Also let me repeat my prior art :) #1314 (comment) And I found another case that will be very happy to have efficient I think there's enough shared knowledge in the ecosystem to see how important it is to have |
|
My thoughts on the current state of things: It seems like we are left with several options wrt. the issue of the agent having to do extra wrapping of these classes.
Overall considerations:
So, how do we make a decision on this? If were forced to make the call, I would choose option 2 (convert to interfaces), and figure out how to optimize representational transformations as a separate concern. |
|
I also support option 2 with the suggestion that the interface documentation indicate that the implementations should be immutable. |
yeah...that's also an interesting consideration. anyone could bring their own implementation and really make a mess of things if it wasn't immutable. I wonder if documentation is good enough in this case. [I tend to think it is..but others might be more wary] |
This is a good point. Are we going to be injecting context into MDC by default though? I think maybe(?) the preferred route is going to be using the new Log SDK: which has the nice benefit of not having to keep the various MDC maps synchronized with our Context values. |
|
@jkwatson having any of these interfaces is a problem because a propagator or id generator needs to generate some objects then they will need to implement the interfaces. An alternative is to do 3, make SC an abstract class or interface and provide multiple implementation (backed by longs by strings by etc.) |
|
I also think we are coming from different worlds, in my experience I never used log correlation (but if that is 90% of the time things may be reconsider) |
@bogdandrutu I'm not following this concern. There would be factory methods that propagators and id generators can use to create the standard instances of these interfaces (they don't need to implement the interfaces). |
I'm not sure why having it be an interface, with the API providing default implementations is a problem. I don't think any of that would change...rather than using a long-based constructor, we would provide creation methods to return the default implementations, which would work for 99.9% of the use-cases. I think it would work similarly to what I've done in #1589 . |
I expect that auto-instrumentation will have log correlation enabled by default. I'm just not sure if it will be MDC-based or OpenTelemetry Log SDK-based. |
Then, why do we want to do this:
|
|
Just re-iterating the point that @anuraaga made above, that MDC <-> Context synchronization will perform very poorly without (at least caching) a string representation of trace id and span id. |
This is a separate concern, we can simply use Strings in the current implementation instead of longs and be done with this issue. |
Discussion with @trask has led me to believe that as long as there is an interface, the agent will have an easier time with the bridging, as it can provide a bridge implementation that would work. I'm prepared to be wrong on that, but that's my understanding from this issue in the instrumentation repo: open-telemetry/opentelemetry-java-instrumentation#975 |
Why not also drop |
|
@trask see my comment:
|
👍 Do we need multiple implementations? Is it ok to just back it by Strings (not longs) since we need the Strings anyways (at least for MDC Context synchronization), and converting String to long is only needed during binary propagation / OTLP export, and can be done on the fly pretty efficiently (zero allocation at least)? |
|
Discussion during the APAC Tuesday meeting yielded the following decisions (for the next step):
|
|
Note: I have opened up a clean PR for further discussion: #1594 |
|
closing with #1594 as the official PR |

This hasn't been optimized, but it is all functionally working, according to the tests.
Another prototype for #1314