-
Notifications
You must be signed in to change notification settings - Fork 821
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: faster span and trace id generation #1349
feat: faster span and trace id generation #1349
Conversation
4fa8e37
to
c175bd7
Compare
Codecov Report
@@ Coverage Diff @@
## master #1349 +/- ##
==========================================
- Coverage 94.15% 94.11% -0.04%
==========================================
Files 145 145
Lines 4326 4334 +8
Branches 881 883 +2
==========================================
+ Hits 4073 4079 +6
- Misses 253 255 +2
|
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 not really sure if we should sacrifice the built in crypto with native support for random bytes towards the pseudo Math.random, I would rather expect the opposite. With regards to benchmarks I would like to see the same benchmarks to be run on some benchmarking pages that compares many different browsers and then compare how much you can gain. But if the gain is couple % we have to think if it is really worth to drop crypto in favour of Math.random
return crypto.randomBytes(SPAN_ID_BYTES).toString('hex'); | ||
export const randomSpanId = getIdGenerator(SPAN_ID_BYTES); | ||
|
||
const SHARED_BUFFER = Buffer.allocUnsafe(TRACE_ID_BYTES); |
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.
what if there are going to be 2 processes that will import the same function and will work asynchronously, any chance for collision on using SHARED_BUFFER
?
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.
No because there is only one execution thread. Nothing here is async.
Why would you expect the opposite? There is nothing in the spec which requires the generated ids to be cryptographically random (nor does it even require them to be random). I could see the code simplicity angle, where using built-ins results in simpler code, but I don't think the code here is particularly complex, and on the browser in particular the new code is significantly simpler and more direct.
The benchmarks collected here are on node, and you can see additional benchmarks in the linked issue, also collected with node. I would like to see tests across many browsers, but I think it is a fairly well-accepted fact that generating cryptographically secure random numbers is more expensive than random numbers without cryptographic guarantees. One additional thing to note aside from the performance improvement, is that this is also impervious to entropy depletion. If the system does not have enough entropy to generate cryptographically random numbers, it can pause. |
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.
the collision chance are really small and we aren't doing anything security sensitive with those ids so the performance gain is worth for me
This is not any more or less likely to collide. The only "loss" is that it may be now possible, but still likely very hard, to guess what the next generated ID will be. |
/cc @Flarna since you helped me with the current id generation |
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.
for node I'm fine, but for browser I'm not so sure if this will be faster without ability to see some benchmarks on most major browsers including IE and edge :/
@obecny I added chrome jsperf benchmark to the PR description which does indeed show a 2x speedup, but I can remove the browser changes if you'd like. |
why would you remove the browser if the speed is 2x faster ? |
Because I can only test on my platform (macos), and you asked for IE/edge. Speedup is consistent in chrome, safari, and firefox though. |
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.
webkit browser are 2 faster, cannot check IE, now but even though it is much better
Just had a coworker test on his windows machine. The old code does not even work on IE as the ArrayBuffer does not support Not sure if this is masked currently by typescript and/or webpack polyfills. |
I added version 2 with msCrypto but it needs some polyfills I guess |
Thanks @dyladan! |
for (let i = 0; i < bytes; i++) { | ||
SHARED_BUFFER[i] = Math.floor(Math.random() * 256); | ||
} | ||
return SHARED_BUFFER.slice(0, bytes).toString('hex'); |
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 think SHARED_BUFFER.toString('hex', 0, bytes)
would avoid creation of a temporary Buffer
with same result.
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.
Looks like this improved time by ~20%
crypto x 427,138 ops/sec ±0.77% (92 runs sampled)
pseudorandom x 2,767,795 ops/sec ±1.00% (93 runs sampled)
pseudorandom no alloc x 3,355,721 ops/sec ±0.96% (90 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.
there is one more thing here to be improved (about 8%)
this line
SHARED_BUFFER[i] = Math.floor(Math.random() * 256);
replace with
SHARED_BUFFER[i] = Math.random() * 256 | 0;
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.
export const randomSpanId = getIdGenerator(SPAN_ID_BYTES); | ||
|
||
const SHARED_BUFFER = Buffer.allocUnsafe(TRACE_ID_BYTES); | ||
function getIdGenerator(bytes: number): () => 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.
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.
We discussed this at one time in the past. Technically it is possible for it to randomly return all 0's, but it is so vanishingly unlikely that I don't think it's worth the extra boolean check every time an id is generated. I can be talked out of this position though.
/cc @open-telemetry/javascript-maintainers
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.
Fair enough @dyladan . One could accept that in the unlikely event that happens then it could be equivalent for the trace to not being sampled.
But, is that what happens in case either traceId
or spanId
is 0? Is the SpanContext gracefully discarded?
I'm asking to understand whether propagators should explicitly check those fields before calling setExtractedSpanContext
. (Note: The B3 and HTTP propagators do check it).
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.
We could create one random byte starting at one instead 0. Just one bit of randomness would be lost.
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.
What do you think of this?
const SHARED_BUFFER = Buffer.allocUnsafe(TRACE_ID_BYTES);
function getIdGenerator(bytes: number): () => string {
return function generateId() {
for (let i = 0; i < bytes / 4; i++) {
SHARED_BUFFER.writeUInt32BE((Math.random() * 2 ** 32) >>> 0, i * 4);
}
// Ensure the last bit is never zero to guarantee a valid w3c id is generated
SHARED_BUFFER[bytes - 1] = SHARED_BUFFER[bytes - 1] | 0x1;
return SHARED_BUFFER.toString('hex', 0, bytes);
};
}
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.
LGTM
function getIdGenerator(bytes: number): () => string { | ||
return function generateId() { | ||
for (let i = 0; i < bytes / 4; i++) { | ||
SHARED_BUFFER.writeUInt32BE((Math.random() * 2 ** 32) >>> 0, i * 4); |
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.
actually the >>> 0
part is not needed as this happens implicit within writeUInt32BE
.
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.
During a benchmark run I recieved the following error:
RangeError [ERR_OUT_OF_RANGE]: The value of "value" is out of range. It must be >= 0 and <= 4294967295. Received 4294967295.2376738
I was able to reproduce the error in the repl:
> const b = Buffer.alloc(4);
undefined
> b.writeUInt32BE(4294967295.2376738, 0);
Thrown:
RangeError [ERR_OUT_OF_RANGE]: The value of "value" is out of range. It must be >= 0 and <= 4294967295. Received 4294967295.2376738
at checkInt (internal/buffer.js:58:11)
at writeU_Int32BE (internal/buffer.js:788:3)
at Buffer.writeUInt32BE (internal/buffer.js:801:10)
at repl:1:3
at Script.runInThisContext (vm.js:116:20)
at REPLServer.defaultEval (repl.js:405:29)
at bound (domain.js:419:14)
at REPLServer.runBound [as eval] (domain.js:432:12)
at REPLServer.onLine (repl.js:716:10)
at REPLServer.emit (events.js:228:7) {
code:
And the following code works as expected
> const b = Buffer.alloc(4);
undefined
> b.writeUInt32BE(4294967295.2376738 >>> 0, 0);
4
> b
<Buffer ff ff ff ff>
It appears, there is a 1/4294967296 chance of generating a random value outside of the range of the function. We have to keep the >>> 0
to guarantee the function stays in range.
On my machine there appears to be no measurable difference between not checking, setting the last bit to 1, or looping until a non-zero byte is found. const Benchmark = require('benchmark');
const benchmarks = require('beautify-benchmark');
const suite = new Benchmark.Suite();
const SHARED_BUFFER = Buffer.allocUnsafe(16);
suite
.add('without check', function () {
for (let i = 0; i < 4; i++) {
SHARED_BUFFER.writeUInt32BE((Math.random() * 2**32), i * 4)
}
SHARED_BUFFER.toString('hex', 0, 16);
})
.add('last bit always 1', function () {
for (let i = 0; i < 4; i++) {
SHARED_BUFFER.writeUInt32BE((Math.random() * 2**32), i * 4)
}
SHARED_BUFFER[15] = SHARED_BUFFER[15] | 1;
SHARED_BUFFER.toString('hex', 0, 16);
})
.add('loop check', function () {
for (let i = 0; i < 4; i++) {
SHARED_BUFFER.writeUInt32BE((Math.random() * 2**32), i * 4)
}
for (let i = 0; i < 16; i++) {
if (SHARED_BUFFER[i] > 0) {
break;
} else if (i === 15) {
SHARED_BUFFER[15] = 1;
}
}
SHARED_BUFFER.toString('hex', 0, 16);
})
// add listeners
.on('cycle', function (event) {
benchmarks.add(event.target);
})
.on('complete', function () {
benchmarks.log();
console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.run();
|
@open-telemetry/javascript-approvers this is done being updated I think, and I would appreciate reviews so we can get this merged. |
Any chance to get this merged? |
@open-telemetry/javascript-approvers would really appreciate some ✅ here :) |
Fixes #1334
/cc @anuraaga
Benchmarks
Noticeable performance improvements in the
BasicTracerProvider
benchmarks with small numbers of attributes.Browser benchmarks: https://jsperf.com/opentelemetry-random-id-generation/1
ID Generation Benchmark
New ID Generation
Old ID Generation