Skip to content
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

[PROF-7440] Add approximate thread state categorization for timeline #3162

Merged
merged 7 commits into from
Oct 3, 2023

Conversation

ivoanjo
Copy link
Member

@ivoanjo ivoanjo commented Sep 27, 2023

What does this PR do?

This PR adds a new feature to the Ruby profiler: thread state categorization.

When taking a cpu/wall-time sample, the profiler will peek at the top of the stack, and then add a state label to the sample that categorizes what the thread was doing.

Motivation:

By categorizing what threads are doing when they're not on the cpu, we can synthesize nice events for timeline, e.g. paint a big rectangle when a thread was blocked on some mutex, or was waiting for the network.

Additional Notes:

Because we're matching only on "name of method at the top of the stack" + "is this method a native method" this approach is approximate, and we hope to replace it with a more accurate mechanism soon.

For now, we will only expose this information as part of the timeline feature.

A few tests needed to be refactored, because they assumed aggregation on the samples that they took, and the new label makes samples not aggregate some times.

I considered if the tests should be changed, or the feature, but concluded that the tests were, in a sense, wrong for assuming aggregation, because in the future, we'll have timeline on by default and in such a future aggregation never happens.

How to test the change?

This change includes code coverage. You can also test it by enabling the timeline feature, and observing that the new states show up on Ruby timeline profiles (you'll need a frontend feature flag to see timeline profiles).

For Datadog employees:

  • If this PR touches code that signs or publishes builds or packages, or handles
    credentials of any kind, I've requested a review from @DataDog/security-design-and-guidance.
  • This PR doesn't touch any of that.

This field is set when there is a cpu+wall sample (e.g. we take both
together), so I decided to rename it internally to make that clearer.

I decided not to change what we emit to pprof.

(This value is not currently shown in the profiling UX)
This is a refactor in preparation for introducing the new `state` label.

The `state` label is different from other labels in that it will be set
after looking at the thread stack. Thus, I needed a way to get more
information about labels from the `ThreadContext` collector to the
`Stack` collector, and thus the `sample_labels` struct was born.
**What does this PR do?**

This PR adds a new feature to the Ruby profiler: thread state
categorization.

When taking a cpu/wall-time sample, the profiler will peek at the
top of the stack, and then add a `state` label to the sample that
categorizes what the thread was doing.

**Motivation:**

By categorizing what threads are doing when they're not on the cpu,
we can synthesize nice events for timeline, e.g. paint a big
rectangle when a thread was blocked on some mutex, or was waiting for
the network.

**Additional Notes:**

Because we're matching only on "name of method at the top of the stack"
+ "is this method a native method" this approach is approximate, and
we hope to replace it with a more accurate mechanism soon.

For now, we will only expose this information as part of the timeline
feature.

A few tests needed to be refactored, because they assumed aggregation
on the samples that they took, and the new label makes samples not
aggregate some times.

I considered if the tests should be changed, or the feature, but
concluded that the tests were, in a sense, wrong for assuming
aggregation, because in the future, we'll have timeline on by default
and in such a future aggregation never happens.

**How to test the change?**

This change includes code coverage. You can also test it by enabling
the timeline feature, and observing that the new states show up
on Ruby timeline profiles (you'll need a frontend feature flag to
see timeline profiles).
@ivoanjo ivoanjo requested review from a team as code owners September 27, 2023 10:58
@github-actions github-actions bot added the profiling Involves Datadog profiling label Sep 27, 2023
At least on Ruby 2.3 the `select` returned immediately, so let's use
a tcp socket to get the same effect.
Compilation was failing with:

```
../../../../ext/ddtrace_profiling_native_extension/collectors_thread_context.c: In function 'trigger_sample_for_thread':
../../../../ext/ddtrace_profiling_native_extension/collectors_thread_context.c:759:5: error: missing initializer for field 'num' of 'ddog_prof_Label' [-Werror=missing-field-initializers]
  759 |     };
      |     ^
In file included from /usr/local/bundle/gems/libdatadog-4.0.0.1.0-x86_64-linux/vendor/libdatadog-4.0.0/x86_64-linux/libdatadog-x86_64-unknown-linux-gnu/lib/pkgconfig/../../include/datadog/profiling.h:12,
                 from ../../../../ext/ddtrace_profiling_native_extension/collectors_stack.h:3,
                 from ../../../../ext/ddtrace_profiling_native_extension/collectors_thread_context.c:5:
/usr/local/bundle/gems/libdatadog-4.0.0.1.0-x86_64-linux/vendor/libdatadog-4.0.0/x86_64-linux/libdatadog-x86_64-unknown-linux-gnu/lib/pkgconfig/../../include/datadog/common.h:369:11: note: 'num' declared here
  369 |   int64_t num;
      |           ^~~
cc1: all warnings being treated as errors
```

which doesn't make a lot of sense since the same pattern works for
other label initializations and doesn't generate a warning/error.

But oh well, it's easier to just make gcc happy.
@codecov-commenter
Copy link

Codecov Report

Merging #3162 (f508e71) into master (157fee6) will increase coverage by 0.00%.
Report is 10 commits behind head on master.
The diff coverage is 99.30%.

@@           Coverage Diff            @@
##           master    #3162    +/-   ##
========================================
  Coverage   98.17%   98.18%            
========================================
  Files        1284     1284            
  Lines       73996    74116   +120     
  Branches     3420     3420            
========================================
+ Hits        72646    72770   +124     
+ Misses       1350     1346     -4     
Files Coverage Δ
spec/datadog/profiling/collectors/stack_spec.rb 98.23% <100.00%> (+0.93%) ⬆️
spec/datadog/profiling/stack_recorder_spec.rb 100.00% <100.00%> (ø)
...atadog/profiling/collectors/thread_context_spec.rb 98.44% <90.00%> (+0.30%) ⬆️

... and 2 files with indirect coverage changes

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

@ivoanjo ivoanjo mentioned this pull request Sep 28, 2023
2 tasks
@@ -171,6 +178,8 @@ void sample_thread(
rb_raise(rb_eArgError, "Unexpected value for sample_type: %d", type);
}

#define CHARSLICE_EQUALS(must_be_a_literal, charslice) (strlen("" must_be_a_literal) == charslice.len && strncmp(must_be_a_literal, charslice.ptr, charslice.len) == 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have an explicit concat with "" in the strlen part but no such concat in the strncmp. I'd expect to need one in both places or neither, what's the deal here? 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is a bit of C dark magic cool trick. It relies on the fact that you can do this in C:

char *foo = "hello " "world";

And the result is concatenated. Note that you can only do this for literals, that is, it's not a generic way of joining strings in C.

In this case, I wanted to force the string being compared to the charslice to be a literal, so by by doing

strlen("" must_be_a_literal)

This causes the compiler to barf if you do this:

        char *sleep_string = "sleep";
        if (CHARSLICE_EQUALS(sleep_string, name_slice)) { 

Output:

../../../../ext/ddtrace_profiling_native_extension/collectors_stack.c: In function ‘sample_thread_internal’:
../../../../ext/ddtrace_profiling_native_extension/collectors_stack.c:288:30: error: expected ‘)’ before ‘sleep_string’
  288 |         if (CHARSLICE_EQUALS(sleep_string, name_slice)) {
      |                              ^~~~~~~~~~~~
../../../../ext/ddtrace_profiling_native_extension/collectors_stack.c:181:67: note: in definition of macro ‘CHARSLICE_EQUALS’
  181 | #define CHARSLICE_EQUALS(must_be_a_literal, charslice) (strlen("" must_be_a_literal) == charslice.len && strncmp(must_be_a_literal, charslice.ptr, charslice.len) == 0)
      |                                                                   ^~~~~~~~~~~~~~~~~
../../../../ext/ddtrace_profiling_native_extension/collectors_stack.c:181:63: note: to match this ‘(’
  181 | #define CHARSLICE_EQUALS(must_be_a_literal, charslice) (strlen("" must_be_a_literal) == charslice.len && strncmp(must_be_a_literal, charslice.ptr, charslice.len) == 0)
      |                                                               ^
../../../../ext/ddtrace_profiling_native_extension/collectors_stack.c:288:13: note: in expansion of macro ‘CHARSLICE_EQUALS’
  288 |         if (CHARSLICE_EQUALS(sleep_string, name_slice)) {
      |             ^~~~~~~~~~~~~~~~

The error could be a bit better but I hope the "must_be_a_literal" hint would lead people in the right direction ;)

I'd expect to need one in both places or neither, what's the deal here? 😄

The concat doesn't need to happen on both because "" must_be_a_literal doesn't do anything else other than cause the error to happen, and otherwise is a no-op. (Doing it on both causes more errors on the compiler side, so from that POV is even slightly worse)


As a final note, why I did this: more out of a tiny cheap micro-optimization; by forcing it to be a literal, I know the compiler can optimize away the strlen; otherwise I would probably have wanted to cache it somewhere, which would be more boilerplate.

) {
state_label->str = DDOG_CHARSLICE_C("blocked");
} else if (CHARSLICE_EQUALS("wait_readable", name_slice)) { // Expected to be IO#wait_readable
state_label->str = DDOG_CHARSLICE_C("network");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume wait_readable could also be applied to file IO. Are we heuristically setting it to network on the assumption that it should be pretty rare to manage to grab a matching sample otherwise?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly.

I'm pretty sure it's possible to do better, by looking at parts of the stack we aren't today to identify the class of instance that wait_readable is being called on, but I suspected that could be an extra can of worms (what if it's a subclass? an anonymous one? etc.) so I went with the approximation.

Comment on lines +15 to +16
// This is used to allow the `Collectors::Stack` to modify the existing label, if any. This MUST be NULL or point
// somewhere inside the labels slice above.
Copy link
Contributor

@AlexJF AlexJF Oct 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Feels a bit weird to have this "pollute" the model/prototypes when it seems to be an internal detail of sample_thread_internal. Is this done purely for performance reasons and do we think the performance impact is great (how many labels do we usually have to deal with?). Seems like we could just as easily look for the label at the time of modification (which with all the guards may not be that often anyway?).

Addendum: After progressing in the review, noticed you do have the sanity check of checking state label exists if it's a CPU/wall sample so I guess we'd be forced to look the label up for almost every sample which may justify the "caching" done here so I have less of an issue with it now and leaving this here just to make sure I understood intentions 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You got it exactly right.

This label was a bit of a new thing, because up until now all labels were set by the thread context collector, and they were opaque to everyone else.

But now suddenly we have this need to have a label that shows up based on the stack, which with the current structure of the profiler happens later than all the other labels.

And yeah, this is something that will happen on every cpu/wall sample, for every thread, with the Ruby VM stopped for sampling, so it's on the hot path and thus we'd need to be careful if we wanted to abstract this to avoid overhead.

So that's why it ended up like this.


As a follow-up note, it doesn't 100% need to be like this. Currently, the array for the labels is stack allocated in the thread context collector, and it's sized to fit exactly the number of labels we need.

If we were to stop stack-allocating it, and instead had a pre-sized buffer that was a bit bigger, we could then keep a pointer to the next free element and the size of the buffer or something like that, and the stack collector could then just add the label when it wants. (Technically we could do that with the stack-allocated array, and my first prototype of this did that, but I didn't quite like how it looked)

The stack collector already has a number of such fixed buffers to store the raw stack sample it gets from the VM, and indeed in a past prototype they started as stack-allocated and then moved to reusable heap-allocated buffers.

Furthermore, if we moved more stuff away from stack allocation, we could have a bit more freedom in sequencing the operations in a sample, e.g. right now we have collectors calling each other as shown in https://github.com/DataDog/dd-trace-rb/blob/master/docs/ProfilingDevelopment.md#how-sampling-happens , but we could move to a more flat approach:

sample the_sample = ...
thread_context.add_labels(the_sample)
thread_context.add_values(the_sample)
stack.add_stack(the_sample)
...
recorder.record(the_sample)

So yeah... Happy to revisit these things as you find them :)

@ivoanjo
Copy link
Member Author

ivoanjo commented Oct 3, 2023

Thanks Alex for the review, merging away! :)

@ivoanjo ivoanjo merged commit 0b0dd29 into master Oct 3, 2023
177 checks passed
@ivoanjo ivoanjo deleted the ivoanjo/prof-7440-timeline-states-v1 branch October 3, 2023 11:35
@github-actions github-actions bot added this to the 1.15.0 milestone Oct 3, 2023
ivoanjo added a commit that referenced this pull request Oct 5, 2023
**What does this PR do?**

This test attempts a fix to the flaky profiler tests that were added in
 #3162 (and that bit us in #3179).

I'm not able to reproduce the issue locally, but I suspect the issue
is that the spec as written was racy:

1. We start a background thread and synchronize with it via a queue
2. The thing we assert on happens AFTER the synchronization (e.g.
   some method called after the queue is used to signal)

Thus, I suspect the issue is that once in a while, Ruby switches
threads at just the wrong time: between 1 and 2, and thus our
assertion fails.

Instead, I've changed the specs  to have a `wait_for` step that waits
for the stack trace of the thread to be what we previously only assumed
it to be, and only then do we run the other assertions.

I call this fix "an attempt" since I wasn't able to reproduce the
issue, BUT overall it's harmless: either I got it right and this
fixes it for got, or I got it wrong, and we'll keep seeing the
flaky specs, and I'll figure out something else to try.

**Motivation:**

Profiler always aims to have no flaky tests.

**Additional Notes:**

N/A

**How to test the change?**

Validate that CI is still green, and stays that way ;)

Fixes DataDog/ruby-guild#53
@ivoanjo ivoanjo mentioned this pull request Oct 5, 2023
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
profiling Involves Datadog profiling
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants