Skip to content

Commit 2587884

Browse files
Kyle-Verhoogjd
andauthored
perf: improve span id generation (#1378)
* add random number generators; use for span id * make threadsafe; add interval generator * experiment with cython * explicitly call __next__ on generators * add build to setup.py * reorganize to use only for python 2 * remove unnecessary thread-safety * assume c-extension * use gil directly for threadsafety * move test to own file * remove benchmark.py from black excludes * use def instead of cpdef * black test_rand.py * fix copy paste error Co-authored-by: Julien Danjou <[email protected]>
1 parent b4f7013 commit 2587884

File tree

8 files changed

+100
-28
lines changed

8 files changed

+100
-28
lines changed

ddtrace/compat.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import platform
2+
import random
23
import re
34
import sys
45
import textwrap
@@ -93,6 +94,12 @@ def process_time_ns():
9394
return int(_process_time() * 1e9)
9495

9596

97+
if sys.version_info.major < 3:
98+
getrandbits = random.SystemRandom().getrandbits
99+
else:
100+
getrandbits = random.getrandbits
101+
102+
96103
if PYTHON_VERSION_INFO[0:2] >= (3, 4):
97104
from asyncio import iscoroutinefunction
98105

ddtrace/internal/_rand.pyx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Generator for pseudorandom 64-bit integers.
2+
3+
Implements the xorshift* algorithm with a non-linear transformation
4+
(multiplication) applied to the result.
5+
6+
This implementation uses the recommended constants from Numerical Recipes
7+
Chapter 7 (Ranq1 algorithm).
8+
9+
According to TPV, the period is approx. 1.8 x 10^19. So it should not be used
10+
by an application that makes more than 10^12 calls.
11+
12+
To put this into perspective: we cap the max number of traces at 1k/s let's be
13+
conservative and say each trace contains 100 spans.
14+
15+
That's 100k spans/second which would be 100k + 1 calls to this fn per second.
16+
17+
That's 10,000,000 seconds until we hit the period. That's 115 days of
18+
100k spans/second (with no application restart) until the period is reached.
19+
20+
21+
rand64bits() is thread-safe as it is written in C and is interfaced with via
22+
a single Python step. This is the same mechanism in which CPython achieves
23+
thread-safety:
24+
https://github.com/python/cpython/blob/8d21aa21f2cbc6d50aab3f420bb23be1d081dac4/Lib/random.py#L37-L38
25+
26+
27+
Python 2.7:
28+
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
29+
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
30+
rand64bits 144.1789 (1.01) 221.2596 (1.0) 155.5800 (1.0) 15.4198 (1.0) 151.3100 (1.00) 7.6687 (1.0) 4;6 6,427.5628 (1.0) 61 100000
31+
random.SystemRandom().getrandbits 1,626.8015 (11.37) 2,178.9074 (9.85) 1,766.1762 (11.35) 133.8990 (8.68) 1,714.4561 (11.35) 113.9164 (14.85) 11;8 566.1949 (0.09) 60 10000
32+
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
33+
34+
35+
Python 3.7:
36+
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Mops/s) Rounds Iterations
37+
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
38+
rand64bits 167.5956 (1.0) 211.3155 (1.0) 190.2803 (1.0) 9.5815 (1.0) 187.7187 (1.0) 11.4513 (1.0) 15;1 5.2554 (1.0) 52 100000
39+
random.randbits 222.7103 (1.33) 367.4459 (1.74) 250.2699 (1.32) 26.5930 (2.78) 242.1607 (1.29) 26.4550 (2.31) 6;1 3.9957 (0.76) 36 100000
40+
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
41+
"""
42+
from libc.stdint cimport uint64_t
43+
44+
from ddtrace import compat
45+
46+
47+
cdef uint64_t x = compat.getrandbits(64) ^ 4101842887655102017
48+
49+
50+
def rand64bits():
51+
global x
52+
x ^= x >> 21
53+
x ^= x << 35
54+
x ^= x >> 4
55+
return x * <uint64_t>2685821657736338717

ddtrace/span.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import math
2-
import random
32
import sys
43
import traceback
54

@@ -16,17 +15,11 @@
1615
)
1716
from .ext import SpanTypes, errors, priority, net, http
1817
from .internal.logger import get_logger
19-
18+
from .internal import _rand
2019

2120
log = get_logger(__name__)
2221

2322

24-
if sys.version_info.major < 3:
25-
_getrandbits = random.SystemRandom().getrandbits
26-
else:
27-
_getrandbits = random.getrandbits
28-
29-
3023
class Span(object):
3124

3225
__slots__ = [
@@ -90,7 +83,7 @@ def __init__(
9083
self.resource = resource or name
9184
self.span_type = span_type.value if isinstance(span_type, SpanTypes) else span_type
9285

93-
# tags / metatdata
86+
# tags / metadata
9487
self.meta = {}
9588
self.error = 0
9689
self.metrics = {}
@@ -100,8 +93,8 @@ def __init__(
10093
self.duration_ns = None
10194

10295
# tracing
103-
self.trace_id = trace_id or _new_id()
104-
self.span_id = span_id or _new_id()
96+
self.trace_id = trace_id or _rand.rand64bits()
97+
self.span_id = span_id or _rand.rand64bits()
10598
self.parent_id = parent_id
10699
self.tracer = tracer
107100

@@ -426,8 +419,3 @@ def __repr__(self):
426419
self.parent_id,
427420
self.name,
428421
)
429-
430-
431-
def _new_id():
432-
"""Generate a random trace_id or span_id"""
433-
return _getrandbits(64)

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ exclude = '''
9191
| tests/
9292
(
9393
base
94-
| benchmark.py
9594
| commands
9695
| contrib/
9796
(

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ def get_exts_for(name):
149149
setup_requires=["setuptools_scm", "cython"],
150150
ext_modules=cythonize(
151151
[
152+
Cython.Distutils.Extension(
153+
"ddtrace.internal._rand", sources=["ddtrace/internal/_rand.pyx"], language="c",
154+
),
152155
Cython.Distutils.Extension(
153156
"ddtrace.profiling.collector.stack",
154157
sources=["ddtrace/profiling/collector/stack.pyx"],

tests/benchmark.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def tracer():
1313

1414
def test_tracer_context(benchmark, tracer):
1515
def func(tracer):
16-
with tracer.trace('a', service='s', resource='r', span_type='t'):
16+
with tracer.trace("a", service="s", resource="r", span_type="t"):
1717
pass
1818

1919
benchmark(func, tracer)
@@ -51,24 +51,20 @@ def func(self):
5151
benchmark(f.func)
5252

5353

54-
def test_tracer_start_span(benchmark, tracer):
55-
benchmark(tracer.start_span, 'benchmark')
56-
57-
5854
def test_tracer_start_finish_span(benchmark, tracer):
5955
def func(tracer):
60-
s = tracer.start_span('benchmark')
56+
s = tracer.start_span("benchmark")
6157
s.finish()
6258

6359
benchmark(func, tracer)
6460

6561

6662
def test_trace_simple_trace(benchmark, tracer):
6763
def func(tracer):
68-
with tracer.trace('parent'):
64+
with tracer.trace("parent"):
6965
for i in range(5):
70-
with tracer.trace('child') as c:
71-
c.set_tag('i', i)
66+
with tracer.trace("child") as c:
67+
c.set_tag("i", i)
7268

7369
benchmark(func, tracer)
7470

@@ -83,10 +79,24 @@ def func(tracer, level=0):
8379

8480
# do some work
8581
num = random.randint(1, 10)
86-
span.set_tag('num', num)
82+
span.set_tag("num", num)
8783

8884
if level < 10:
8985
func(tracer, level + 1)
9086
func(tracer, level + 1)
9187

9288
benchmark(func, tracer)
89+
90+
91+
def test_tracer_start_span(benchmark, tracer):
92+
benchmark(tracer.start_span, "benchmark")
93+
94+
95+
@pytest.mark.benchmark(group="span-id", min_time=0.005)
96+
def test_span_id_rand64bits(benchmark):
97+
from ddtrace.internal import _rand
98+
99+
@benchmark
100+
def f():
101+
_ = _rand.rand64bits()
102+
_ = _rand.rand64bits()

tests/test_rand.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from ddtrace.internal import _rand
2+
3+
4+
def test_random():
5+
m = set()
6+
for i in range(0, 2 ** 16):
7+
n = _rand.rand64bits()
8+
assert 0 <= n <= 2 ** 64 - 1
9+
assert n not in m
10+
m.add(n)

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ isolated_build = true
158158
# meaning running on py3.x will fail
159159
# https://stackoverflow.com/questions/57459123/why-do-i-need-to-run-tox-twice-to-test-a-python-package-with-c-extension
160160
whitelist_externals=rm
161-
commands_pre=rm -f ddtrace/profiling/_build.c ddtrace/profiling/collector/stack.c ddtrace/profiling/collector/_traceback.c
161+
commands_pre=rm -f ddtrace/profiling/_build.c ddtrace/profiling/collector/stack.c ddtrace/profiling/collector/_traceback.c ddtrace/internal/_rand.c
162162
{envpython} {toxinidir}/setup.py develop
163163
usedevelop =
164164
# do not use develop mode with celery as running multiple python versions within

0 commit comments

Comments
 (0)