-
Notifications
You must be signed in to change notification settings - Fork 896
feat(api): improve isValidSpanId, isValidTraceId performance #5714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(api): improve isValidSpanId, isValidTraceId performance #5714
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## main #5714 +/- ##
=======================================
Coverage 95.00% 95.01%
=======================================
Files 303 303
Lines 7952 7957 +5
Branches 1609 1610 +1
=======================================
+ Hits 7555 7560 +5
Misses 397 397
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
I think some information about that look-up table could be helpful for someone that comes across this code wondering what it is (I happened to have seen this elsewhere but I don't know if it's common to see this sort of thing in JS).
If this is an issue I can add a check for an undefined input (even though according to the types the input can only be string).
My initial impulse would be to leave it out and see if it's a problem, but let's be safe and add a check since it's the API package. 😅
api/src/trace/spancontext-utils.ts
Outdated
let r = 0; | ||
for (let i = 0; i < id.length; i += 4) { | ||
r += | ||
isHex[id.charCodeAt(i) & 0x7f] + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe also a comment about masking so that this only lowest 7 bits are retained.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added an explanation for the bitwise and
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is possible using charCodeAt
to get partial code points from an extended-unicode code point containing string. Should we be using codePointAt
? It would then also mean the number might be larger than 128 though (up to 2^16
I think)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment got me thinking that this implementation had an error, where an UTF-16 value like \u4141
(䅁) would be truncated to 0x41
, or A
, which is a valid character and an invalid ID containing such value would pass.
I changed the implementation to LUT[id.charCodeAt(i)] | 0
which coerces undefined to 0 and fixes the issue (while being faster than the original). See the chart - LUT unroll x 4
vs LUT unroll x 4
old.
As for codePointAt
, I can't think of a case where a Unicode character taking up multiple UTF-16 values would have all of the UTF-16 characters in the range in the lookup table ('a-z', 'A-Z, '0-9'). Besides that, it seems String.length
should not be used with codePointAt
.
I've profiled different implementations now, here's the data:
The implementation in the PR is U8 LUT, unroll x 4
.
You can try it out yourself: https://gist.github.com/seemk/f00c432d8779a03dbe1a28236f9dd198
add comments
I reverted the test and added a check for undefined (plus the comments) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be just as fast and easier to read if it was a set?
const hexChars = new Set([
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"a", "b", "c", "d", "e", "f",
"A", "B", "C", "D", "E", "F",
]);
function isValidHex(id: string, length: number) {
// As of 1.9.0 the id was allowed to be undefined,
// even though it was not possible in the types.
if (id === undefined || id.length !== length) return false;
for (const c of id) {
if (!hexChars.has(c)) {
return false;
}
}
return true;
}
api/src/trace/spancontext-utils.ts
Outdated
const VALID_TRACEID_REGEX = /^([0-9a-f]{32})$/i; | ||
const VALID_SPANID_REGEX = /^[0-9a-f]{16}$/i; | ||
// Valid characters (0-9, a-f, A-F) are marked as 1. | ||
const isHex = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, Node core implements this with an Int8Array
, which might be more efficient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems to be about the same, with the Int8Array based version being a few percent slower (no idea why, could be noise):
isValidTraceId (LUT, unroll x4) 1474.1 ms
isValidTraceId (Int8Array LUT, unroll x4) 1562.7 ms
isValidSpanId (LUT, unroll x4) 748.7 ms
isValidSpanId (Int8Array LUT, unroll x4) 776.7 ms
isValidTraceId (LUT, unroll x4) x 29,297,057 ops/sec ±0.48% (96 runs sampled)
isValidTraceId (Int8Array LUT, unroll x4) x 31,043,607 ops/sec ±0.61% (95 runs sampled)
isValidSpanId (LUT, unroll x4) x 51,491,271 ops/sec ±0.79% (96 runs sampled)
isValidSpanId (Int8Array LUT, unroll x4) x 54,356,052 ops/sec ±1.02% (94 runs sampled)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I benchmarked this with jsbench.me and changing this to be a Uint8Array
sped it up by about 12% for me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems to be slightly faster on x64, see the results here: #5714 (comment) (there's a link to the benchmark script if you want to run it yourself).
I changed the implementation to use Uint8Array
.
api/src/trace/spancontext-utils.ts
Outdated
function isValidHex(id: string, length: number): boolean { | ||
// As of 1.9.0 the id was allowed to be undefined, | ||
// even though it was not possible in the types. | ||
if (id === undefined || id.length !== length) return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of checking only for undefined
, would it be more robust to check typeof id !== 'string'
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, changed it to typeof id !== 'string'
It's actually slower than the regex version:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm marking this approved but I have a couple more options that I think would be at least interesting to see the benchmark results since you already have a benchmark setup. You don't need to take either of them, I just think this type of micro-optimization is fun and you nerd-sniped me a bit.
The first version uses simple arithmetic instead of a LUT which may be more readable.
function isHexArithmetic(str) {
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
if (charCode < 48 || (charCode > 57 && charCode < 65) || (charCode > 70 && charCode < 97) || charCode > 102) {
return false;
}
}
return true;
}
The second version takes advantage of the contiguous memory of TypedArrays and completely throws readability out the window
const LUT = new Uint8Array([
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b11111111, 0b00000011, 0b01111110, 0b00000000, 0b00000000, 0b00000000,
0b01111110, 0b00000000, 0b00000000, 0b00000000,
]);
function isHexPackedLUT(str) {
for (let i = 0; i < str.length; i++) {
const charCode = str.codePointAt(i);
if (!(LUT[charCode >> 3] & (1 << (charCode & 7)))) {
return false;
}
}
return true;
}
edit: for those wondering what is going on in the packed LUT version
LUT
is a simple bit-array. It's just a tightly packed version of the one used in the PR. Notice that each byte is reverse order though. That's important because while the left-most byte is the first byte, the right-most bit is the first bit of each byte.>> 3
divides by 8 with no remainder to determine which byte we're looking at& 7
gets the remainder of division by 8 to determine which bit we're looking at in that byte1
is left shifted that many times to allow us to do the bitwise&
I tested an if else based one initially, but left it out, here are the results: With valid hex strings (which sould be the usual case for OTel):
With malformed hex strings (invalid hex character at random position):
The packed LUT seems to do surprisingly well and is about 10% faster in the negative cases (likely due to the early exit) and only about 15-20% slower in the most common path. |
Short description of the changes
Use a lookup table to check for hex string validity instead of regexes.
Changed a test where a string was cast into
SpanContext
. Previously the test calledisSpanContextValid
with a string instead ofSpanContext
, soundefined
spanId
andtraceId
were passed toisValidTraceId
andisValidSpanId
. The regex implementation handled this implicitly, but this had never been exposed to the types. If this is an issue I can add a check for anundefined
input (even though according to the types the input can only be string).Tested in two different ways:
Test 1 - Total time to process 50 runs of 1M pregenerated IDs
Test 2 - Output of the
benchmark
libraryMixed IDs (equal amount of valid and invalid IDs)
All valid IDs
All random invalid IDs (randomly generated ID with an invalid character appearing at a random location)
INVALID_{TRACE,SPAN}_ID - all zeroes