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

TraceQL: support mixed-type attribute querying (int/float) #4391

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

ndk
Copy link
Contributor

@ndk ndk commented Nov 27, 2024

What this PR does:
Below is my understanding of the current limitations. Please feel free to correct me if I’ve misunderstood or overlooked something.

Attributes of the same type are stored in the same column. For example, integers are stored in one column and floats in another.

Querying operates in two stages:

  • Predicate Creation: Predicates are created based on the operand types.
  • Chunk Scanning: Chunks are scanned, and spans are filtered using the predicates.

The issue arises because predicates are generated based on the operand type. If an attribute is stored as a float but the operand is an integer, the predicate evaluates against the integers column instead of the floats column. This results in incorrect behavior.

Proposed Solution
The idea is to generate predicates for both integers and floats, allowing both columns to be scanned for the queried attribute.

In this PR, I’ve created a proof-of-concept by copying the existing createAttributeIterator function to createAttributeIterator2. This duplication is intentional, as the original function is used in multiple places, and I want to avoid introducing unintended side effects until the approach is validated.

case traceql.TypeInt:
	{
		pred, err := createIntPredicate(cond.Op, cond.Operands)
		if err != nil {
			return nil, fmt.Errorf("creating attribute predicate: %w", err)
		}
		attrIntPreds = append(attrIntPreds, pred)
	}

	{
		if i, ok := cond.Operands[0].Int(); ok {
			operands := traceql.Operands{traceql.NewStaticFloat(float64(i))}
			pred, err := createFloatPredicate(cond.Op, operands)
			if err != nil {
				return nil, fmt.Errorf("creating attribute predicate: %w", err)
			}
			attrFltPreds = append(attrFltPreds, pred)
		}
	}

case traceql.TypeFloat:
	{
		operands := traceql.Operands{traceql.NewStaticInt(int(cond.Operands[0].Float()))}
		pred, err := createIntPredicate(cond.Op, operands)
		if err != nil {
			return nil, fmt.Errorf("creating attribute predicate: %w", err)
		}
		attrIntPreds = append(attrIntPreds, pred)
	}

	{
		pred, err := createFloatPredicate(cond.Op, cond.Operands)
		if err != nil {
			return nil, fmt.Errorf("creating attribute predicate: %w", err)
		}
		attrFltPreds = append(attrFltPreds, pred)
	}

WDYT? :)

Which issue(s) this PR fixes:
Fixes #4332

Checklist

  • Tests updated
  • Documentation added
  • CHANGELOG.md updated - the order of entries should be [CHANGE], [FEATURE], [ENHANCEMENT], [BUGFIX]

@ndk ndk changed the title WIP: Proposal to address mixed-type attribute querying limitations TraceQL: Proposal to address mixed-type attribute querying limitations Nov 27, 2024
@ndk ndk changed the title TraceQL: Proposal to address mixed-type attribute querying limitations WIP: Proposal to address mixed-type attribute querying limitations Dec 19, 2024
@joe-elliott
Copy link
Member

I apologize for taking so long to get to this. Your analysis is correct! We do generate predicates per column and, since we store integers and floats independently we only scan one of the columns. Given how small int and float columns tend to be (compared to string columns) I think the performance hit of doing this is likely acceptable in exchange for the nicer behavior.

What is the behavior in this case? I'm pretty sure this will work b/c the engine will request all values for the two attributes and do the work itself. I believe the engine layer will compare ints and floats correctly but I'm not 100% sure.

{ span.intAttr > span.floatAttr }

Tests should also be added here for the new behavior. These tests build a block and then search for a known trace using a large range of traceql queries. If you add tests here and they pass it means that your changes work from the parquet file all the way up through the engine.

This will also break the "allConditions" optimization if the user types any query with a number comparison:

https://github.com/grafana/tempo/pull/4391/files#diff-a201423ab0b50d4455a497bf1804b1a9f596394413c28b7702710f89237c49c1R2815-R2821

I would like preserve the allConditions behavior in this case b/c it's such a nice optimization and number queries are common. I'm not quite sure why the len(valueIters) == 1 condition exists so we'd need to do some research into it.

@ndk ndk force-pushed the mixed-type-attr-query branch 2 times, most recently from 3d8f31d to 812d768 Compare January 10, 2025 16:41
@ndk
Copy link
Contributor Author

ndk commented Jan 12, 2025

I apologize for taking so long to get to this. Your analysis is correct! We do generate predicates per column and, since we store integers and floats independently we only scan one of the columns. Given how small int and float columns tend to be (compared to string columns) I think the performance hit of doing this is likely acceptable in exchange for the nicer behavior.

Thank you for confirming the approach and pointing out the allConditions optimization. Right now, the fix scans both integer and float columns for attributes that might be either type. I’ve also adjusted how float comparisons work for integer fields, taking into account the fraction part and the comparison operator.

What is the behavior in this case? I'm pretty sure this will work b/c the engine will request all values for the two attributes and do the work itself. I believe the engine layer will compare ints and floats correctly but I'm not 100% sure.

{ span.intAttr > span.floatAttr }

I verified that { span.intAttr > span.floatAttr } behaves as expected. Wanna me to add a test to cover this case?

Tests should also be added here for the new behavior. These tests build a block and then search for a known trace using a large range of traceql queries. If you add tests here and they pass it means that your changes work from the parquet file all the way up through the engine.

Done. Let me know if I missed something.

This will also break the "allConditions" optimization if the user types any query with a number comparison:

https://github.com/grafana/tempo/pull/4391/files#diff-a201423ab0b50d4455a497bf1804b1a9f596394413c28b7702710f89237c49c1R2815-R2821

I would like preserve the allConditions behavior in this case b/c it's such a nice optimization and number queries are common. I'm not quite sure why the len(valueIters) == 1 condition exists so we'd need to do some research into it.

Regarding the allConditions block, the optimization is lost because we generate two predicates (one for int, one for float) under the same attribute name, triggering a LeftJoinIterator instead of a JoinIterator. Possible workarounds I’m considering:

  • Creating a variant of JoinIterator that uses logical OR rather than AND.
  • Exploring parquet.multiRowGroup, parquetquery.UnionIterator, or parquetquery.KeyValueGroupPredicate to see if they can unify the int/float search without losing the optimization.
  • Refactoring a single ColumnChunk to the multy-one.

Given my limited exposure to Tempo’s internals, I’d appreciate any guidance on whether these routes are viable or if there’s a simpler approach to preserve allConditions.

P.S. Do we care about comparisons with negative values? Should it also be covered?

@ndk ndk changed the title WIP: Proposal to address mixed-type attribute querying limitations Mixed-type attribute querying (int/float) Jan 12, 2025
@ndk ndk marked this pull request as ready for review January 12, 2025 11:42
@ndk ndk force-pushed the mixed-type-attr-query branch from 812d768 to 171afab Compare January 12, 2025 12:42
@ndk ndk changed the title Mixed-type attribute querying (int/float) TraceQL: support mixed-type attribute querying (int/float) Jan 12, 2025
@ndk ndk force-pushed the mixed-type-attr-query branch 3 times, most recently from 4970fbd to 50f5ae5 Compare January 14, 2025 16:09
@joe-elliott
Copy link
Member

joe-elliott commented Jan 14, 2025

This is a really cool change. Ran benchmarks and found no major regressions. Nice tests added ./tempodb. We try to keep those as comprehensive as possible given the complexity of the language.

I verified that { span.intAttr > span.floatAttr } behaves as expected. Wanna me to add a test to cover this case?

This case is covered in the ./pkg/traceql tests so I wouldn't worry about it. It occurred to me that this case causes two "OpNone" conditions to the fetch layer and the condition itself is evaluated in the engine, so your changes will not impact it.

I’ve also adjusted how float comparisons work for integer fields, taking into account the fraction part and the comparison operator.

Nice improvements here. I like falling back to integer comparison (or nothing) based on if the float has a fractional part.

Regarding the allConditions block, the optimization ...

The right choice would be a UnionOperator on the columns. It would be interesting to compare the performance of that against what you have currently written. I'm less concerned about allConditions then I was previously b/c the root iterators will still behave as if allConditions is true which is what really drives performance. The benchmarks show your changes are not causing a regression. I'm fine with what you have now, but feel free to experiment with union if you want.

Also, if you're interested, plug your queries into this test and run it. It will dump the iterator structure and you can see how your changes have impacted the hierarchy.

P.S. Do we care about comparisons with negative values? Should it also be covered?

Yes, are they not already? reviewing your code I think they would work fine.

I think my primary ask at this point would be to keep the int and float switch cases symmetrical. Even though it's trivial can you create a createFloatPredicateFromInt()? If these two cases read the same line by line it will be easier for others to understand what was done here in the future.

I'm a bit impressed you're taking this on. I wouldn't have guessed someone outside of Grafana would have had the time and patience to find this.

benches
goos: darwin
goarch: arm64
pkg: github.com/grafana/tempo/tempodb/encoding/vparquet4
cpu: Apple M3 Pro
                                                    │ before.txt  │             after.txt              │
                                                    │   sec/op    │   sec/op     vs base               │
BackendBlockTraceQL/spanAttValMatch-11                94.58m ± 0%   95.15m ± 1%  +0.60% (p=0.007 n=10)
BackendBlockTraceQL/spanAttValNoMatch-11              4.913m ± 1%   4.979m ± 1%  +1.34% (p=0.001 n=10)
BackendBlockTraceQL/spanAttIntrinsicMatch-11          71.23m ± 0%   72.79m ± 1%  +2.18% (p=0.000 n=10)
BackendBlockTraceQL/spanAttIntrinsicNoMatch-11        4.940m ± 0%   5.031m ± 0%  +1.83% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttValMatch-11            408.4m ± 1%   413.0m ± 1%  +1.13% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttValNoMatch-11          5.070m ± 1%   5.181m ± 1%  +2.20% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttIntrinsicMatch-11      37.06m ± 0%   37.80m ± 1%  +2.00% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttIntrinsicMatch#01-11   4.891m ± 1%   4.954m ± 1%  +1.29% (p=0.001 n=10)
BackendBlockTraceQL/traceOrMatch-11                   238.5m ± 0%   241.3m ± 0%  +1.17% (p=0.000 n=10)
BackendBlockTraceQL/traceOrNoMatch-11                 238.7m ± 1%   241.4m ± 0%  +1.14% (p=0.000 n=10)
BackendBlockTraceQL/mixedValNoMatch-11                179.1m ± 0%   179.2m ± 0%       ~ (p=0.190 n=10)
BackendBlockTraceQL/mixedValMixedMatchAnd-11          4.949m ± 0%   5.030m ± 0%  +1.64% (p=0.000 n=10)
BackendBlockTraceQL/mixedValMixedMatchOr-11           148.9m ± 1%   148.6m ± 0%       ~ (p=0.529 n=10)
BackendBlockTraceQL/count-11                          340.5m ± 3%   341.2m ± 0%       ~ (p=0.218 n=10)
BackendBlockTraceQL/struct-11                         432.6m ± 2%   432.8m ± 3%       ~ (p=0.796 n=10)
BackendBlockTraceQL/||-11                             165.8m ± 0%   166.1m ± 0%       ~ (p=0.089 n=10)
BackendBlockTraceQL/mixed-11                          28.86m ± 1%   29.08m ± 0%       ~ (p=0.123 n=10)
BackendBlockTraceQL/complex-11                        4.918m ± 4%   4.969m ± 5%       ~ (p=0.123 n=10)
BackendBlockTraceQL/select-11                         4.918m ± 0%   4.999m ± 0%  +1.64% (p=0.000 n=10)
geomean                                               42.28m        42.72m       +1.06%

                                                    │  before.txt  │              after.txt              │
                                                    │     B/s      │     B/s       vs base               │
BackendBlockTraceQL/spanAttValMatch-11                236.8Mi ± 0%   235.4Mi ± 1%  -0.60% (p=0.007 n=10)
BackendBlockTraceQL/spanAttValNoMatch-11              343.9Mi ± 1%   339.4Mi ± 1%  -1.32% (p=0.001 n=10)
BackendBlockTraceQL/spanAttIntrinsicMatch-11          327.0Mi ± 0%   320.0Mi ± 1%  -2.14% (p=0.000 n=10)
BackendBlockTraceQL/spanAttIntrinsicNoMatch-11        501.5Mi ± 0%   492.4Mi ± 0%  -1.80% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttValMatch-11            53.81Mi ± 1%   53.21Mi ± 1%  -1.12% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttValNoMatch-11          177.4Mi ± 1%   173.6Mi ± 1%  -2.16% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttIntrinsicMatch-11      594.1Mi ± 0%   582.4Mi ± 1%  -1.96% (p=0.000 n=10)
BackendBlockTraceQL/resourceAttIntrinsicMatch#01-11   190.9Mi ± 1%   188.5Mi ± 1%  -1.28% (p=0.001 n=10)
BackendBlockTraceQL/traceOrMatch-11                   7.010Mi ± 0%   6.928Mi ± 0%  -1.16% (p=0.000 n=10)
BackendBlockTraceQL/traceOrNoMatch-11                 7.005Mi ± 1%   6.924Mi ± 0%  -1.16% (p=0.000 n=10)
BackendBlockTraceQL/mixedValNoMatch-11                11.01Mi ± 0%   11.00Mi ± 0%       ~ (p=0.303 n=10)
BackendBlockTraceQL/mixedValMixedMatchAnd-11          180.4Mi ± 0%   177.5Mi ± 0%  -1.61% (p=0.000 n=10)
BackendBlockTraceQL/mixedValMixedMatchOr-11           18.52Mi ± 1%   18.56Mi ± 0%       ~ (p=0.492 n=10)
BackendBlockTraceQL/count-11                          64.51Mi ± 2%   64.39Mi ± 0%       ~ (p=0.197 n=10)
BackendBlockTraceQL/struct-11                         12.62Mi ± 2%   12.62Mi ± 3%       ~ (p=0.837 n=10)
BackendBlockTraceQL/||-11                             133.1Mi ± 0%   132.9Mi ± 0%       ~ (p=0.085 n=10)
BackendBlockTraceQL/mixed-11                          740.2Mi ± 1%   734.8Mi ± 0%       ~ (p=0.123 n=10)
BackendBlockTraceQL/complex-11                        183.0Mi ± 4%   181.1Mi ± 5%       ~ (p=0.123 n=10)
BackendBlockTraceQL/select-11                         183.0Mi ± 0%   180.0Mi ± 0%  -1.61% (p=0.000 n=10)
geomean                                               98.15Mi        97.12Mi       -1.05%

                                                    │ before.txt  │              after.txt               │
                                                    │  MB_io/op   │  MB_io/op    vs base                 │
BackendBlockTraceQL/spanAttValMatch-11                 23.48 ± 0%    23.48 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/spanAttValNoMatch-11               1.772 ± 0%    1.772 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/spanAttIntrinsicMatch-11           24.43 ± 0%    24.43 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/spanAttIntrinsicNoMatch-11         2.598 ± 0%    2.598 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/resourceAttValMatch-11             23.04 ± 0%    23.04 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/resourceAttValNoMatch-11          943.2m ± 0%   943.2m ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/resourceAttIntrinsicMatch-11       23.09 ± 0%    23.09 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/resourceAttIntrinsicMatch#01-11   979.0m ± 0%   979.0m ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/traceOrMatch-11                    1.753 ± 0%    1.753 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/traceOrNoMatch-11                  1.753 ± 0%    1.753 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/mixedValNoMatch-11                 2.067 ± 0%    2.067 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/mixedValMixedMatchAnd-11          936.1m ± 0%   936.1m ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/mixedValMixedMatchOr-11            2.893 ± 0%    2.893 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/count-11                           23.03 ± 0%    23.03 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/struct-11                          5.726 ± 0%    5.726 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/||-11                              23.14 ± 0%    23.14 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/mixed-11                           22.40 ± 0%    22.40 ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/complex-11                        943.7m ± 0%   943.7m ± 0%       ~ (p=1.000 n=10) ¹
BackendBlockTraceQL/select-11                         943.7m ± 0%   943.7m ± 0%       ~ (p=1.000 n=10) ¹
geomean                                                4.351         4.351       +0.00%
¹ all samples are equal

@ndk ndk force-pushed the mixed-type-attr-query branch from b0ffcde to 10b04c5 Compare January 15, 2025 01:25
@ndk
Copy link
Contributor Author

ndk commented Jan 15, 2025

I'm fine with what you have now, but feel free to experiment with union if you want.

I'm not sure if it's worth it. I'd rather rely on your opinion here.

P.S. Do we care about comparisons with negative values? Should it also be covered?

Yes, are they not already? reviewing your code I think they would work fine.

Actually, it turned out they didn't work correctly with negative values. I've updated the shifting logic to fix this. Also, another edge case raises questions: what happens if a float hits MaxInt/MinInt? In some cases, it might cause jumps between MaxInt and MinInt.

I think my primary ask...

Done! Let me know if this aligns with what you had in mind.

Plus, I've added some tests in a separate commit. Feel free to let me know if they look odd or need adjustments.

I'm a bit impressed you're taking this on. I wouldn't have guessed someone outside of Grafana would have had the time and patience to find this.

Haha, thanks! Honestly, it's just curiosity. Tempo is a fascinating system, and I've wanted to dive into something challenging like this. It's fun to learn from real-world systems and see how they tackle performance and scalability. :)

@ndk ndk force-pushed the mixed-type-attr-query branch 2 times, most recently from c05b608 to a679803 Compare January 17, 2025 14:01
@joe-elliott
Copy link
Member

Also, another edge case raises questions: what happens if a float hits MaxInt/MinInt? In some cases, it might cause jumps between MaxInt and MinInt.

We could try to get tricky here. Like if you do { span.IntCol > IntMaxAsFloat } then we just don't do the int comparison. { span.IntCol < IntMaxAsFloat } would just return all values from the fetch layer. But I'm also fine with the easy path of just not attempting the float/int comparison is the float is outside the bounds of the int column. It feels like an acceptable edge case as long as we document it.

Done! Let me know if this aligns with what you had in mind.

Yup, I think this communicates better to a future reader what's going on. Thanks for the change.

Ok, I was running your branch on Friday to test and we do have one final thing to figure out. This query does not work:

{ span.http.status_code = 200.0 }

The reason is b/c we handle this special column here:

if entry, ok := wellKnownColumnLookups[cond.Attribute.Name]; ok && entry.level != traceql.AttributeScopeResource {
if cond.Op == traceql.OpNone {
addPredicate(entry.columnPath, nil) // No filtering
columnSelectAs[entry.columnPath] = cond.Attribute.Name
continue
}
// Compatible type?
if entry.typ == operandType(cond.Operands) {
pred, err := createPredicate(cond.Op, cond.Operands)
if err != nil {
return nil, fmt.Errorf("creating predicate: %w", err)
}
addPredicate(entry.columnPath, pred)
columnSelectAs[entry.columnPath] = cond.Attribute.Name
continue
}
}

All well known and dedicated columns are strings ... except this one unfortunately. To do this correctly we have to scan both the well known column as well as the general float attribute column if the static value being compared against http status code is a float. To do this performantly I think we will need to build a UnionIterator that joins two sub iterators. One that scans the well known column and one that scans the float attribute column with the appropriate predicate.

@ndk
Copy link
Contributor Author

ndk commented Jan 22, 2025

We could try to get tricky here. Like if you do { span.IntCol > IntMaxAsFloat } then we just don't do the int comparison. { span.IntCol < IntMaxAsFloat } would just return all values from the fetch layer.

Sounds like a plan. Will do it later. :)

{ span.http.status_code = 200.0 }

...
All well known and dedicated columns are strings ... except this one unfortunately. To do this correctly we have to scan both the well known column as well as the general float attribute column if the static value being compared against http status code is a float. To do this performantly I think we will need to build a UnionIterator that joins two sub iterators. One that scans the well known column and one that scans the float attribute column with the appropriate predicate.

Oh, that's a nice catch! But before rushing into handling this case, I want to address one quick concern. If a user specifies span.http.status_code = 200.0, isn't that likely just a typo? Automatically converting floats to ints might hide the mistake instead of surfacing it. Even though status codes are technically numbers, they're more like categorical values. 199 isn't "slightly less successful" than 200. It's a completely different outcome.

Anyway, if you see real value in covering this edge case, I'm happy to implement it. Let me know what you think!

P.S. I found out that I should convert int to float64 carefully if 2^53 < int <= MaxInt

@joe-elliott
Copy link
Member

If a user specifies span.http.status_code = 200.0 ..

yeah, that does kind of feel like a typo, but there's nothing special in the language about span.http.status_code. it's an integer span attribute just like all of the others and should be treated the same. the real improvement is that when someone types

{ span.http.status_code = 200 }

it will find float status codes and return spans appropriately. i don't know why but for some reason we have float status codes all over the place in our internal Tempo installation.

@ndk ndk force-pushed the mixed-type-attr-query branch from a679803 to 4386237 Compare January 31, 2025 15:15
@ndk ndk changed the title TraceQL: support mixed-type attribute querying (int/float) WIP TraceQL: support mixed-type attribute querying (int/float) Feb 4, 2025
@ndk ndk marked this pull request as draft February 4, 2025 13:07
@joe-elliott
Copy link
Member

It's funny because I really want this PR in, but the only thing blocking it is handling http status code correctly. However, I'd really like to cut a vparquet5 that removes all well known columns (and other cleanup) which would unblock this PR.

@ndk
Copy link
Contributor Author

ndk commented Feb 5, 2025

I believe that I finally learn on how to use UnionInterator to handle http status codes properly, but need more time to cover with tests to ensure if I didn't screw something up. I'll move the PR from draft state when I push the updated version. :)

@ndk ndk force-pushed the mixed-type-attr-query branch 2 times, most recently from 7222fa2 to 9ac7d86 Compare February 6, 2025 15:38
@joe-elliott
Copy link
Member

What's vparquet5? Is something coming?

I wish we had time to work on this. It's an undefined cleanup pass on vParquet with a focus on reducing complexity, number of columns and footer size. One of the things I'd like accomplished is removing the well known columns and instead relying on dedicated columns.

@ndk

This comment was marked as outdated.

@ndk ndk force-pushed the mixed-type-attr-query branch 3 times, most recently from 63aa82c to 69aa605 Compare February 6, 2025 23:15
@ndk ndk marked this pull request as ready for review February 6, 2025 23:22
@ndk ndk changed the title WIP TraceQL: support mixed-type attribute querying (int/float) TraceQL: support mixed-type attribute querying (int/float) Feb 6, 2025
@joe-elliott
Copy link
Member

Tested and works! but there's definitely some cleanup to do.

{ span.http.status_code = 200 } and { span.http.status_code = 200. } are doing more work than necessary. They are creating this iterator structure:

UnionIterator: 3: %!s(<nil>)
	SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{200}
	LeftJoinIterator: 4: attributeCollector{}
	required:
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.Key : StringInPredicate{http.status_code}
	optional:
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{200.000000}
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.ValueInt.list.element : IntEqualPredicate{200})

but we don't need to scan the generic attribute column for an int. Int values are guaranteed to be stored in the dedicated column for this attribute name so we only need to scan the generic column for a float. This should simplify the iterators to something like:

UnionIterator: 3: %!s(<nil>)
	SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{200}
	JoinIterator: 4: attributeCollector{}
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.Key : StringInPredicate{http.status_code}
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{200.000000}

unsure why you're seeing nils. I can dig into that a bit. we shouldn't need the filter nil thing.

@ndk ndk force-pushed the mixed-type-attr-query branch from 69aa605 to 7e2974d Compare February 7, 2025 17:27
@ndk
Copy link
Contributor Author

ndk commented Feb 7, 2025

Oh my gosh! This is what happens when a review lasting too long. I started forgetting what I've been doing. :D Fixed.

{ span.http.status_code = 200 && span.http.status_code = 200. } -> iterator structure

LeftJoinIterator: 3: spanCollector(1)
required: 
	UnionIterator: 3: %!s(<nil>)	
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{200}
		SyncIterator: rs.list.element.Resource.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{200.000000})
	UnionIterator: 3: %!s(<nil>)	
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{200}
		SyncIterator: rs.list.element.Resource.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{200.000000})

If it looks good, there's one more step remaining - need to update vparquet3 and vparquet2

Copy link
Member

@joe-elliott joe-elliott left a comment

Choose a reason for hiding this comment

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

Awesome! it looks like we were able to get rid of those nil filter shenanigans. I think this is very very close. All functionality is accounted for. I did run some benchmarks and find a regression we should spend some time to understand. I do expect a bit of overhead due to this change but one particular query is showing a 20% increase in cpu.

I can help dig into this.

These are the queries used in the benches. The regression occurred on traceOrMatch which you can see below. As you can tell they are crafted for internal data, but they can be rewritten for any block where they get some matches.

statuscode:  { span.http.status_code = 200 }
traceOrMatch: { rootServiceName = `tempo-gateway` && (status = error || span.http.status_code = 500)}
complex: {resource.cluster=~"prod.*" && resource.namespace = "tempo-prod" && resource.container="query-frontend" && name = "HTTP GET - tempo_api_v2_search_tags" && span.http.status_code = 200 && duration > 1s}
benches
> benchstat before.txt after.txt
goos: darwin
goarch: arm64
pkg: github.com/grafana/tempo/tempodb/encoding/vparquet4
cpu: Apple M3 Pro
                                    │ before.txt  │              after.txt              │
                                    │   sec/op    │   sec/op     vs base                │
BackendBlockTraceQL/statuscode-11     64.28m ± 2%   64.81m ± 1%   +0.83% (p=0.043 n=10)
BackendBlockTraceQL/traceOrMatch-11   249.8m ± 8%   303.7m ± 9%  +21.59% (p=0.000 n=10)
BackendBlockTraceQL/complex-11        4.944m ± 5%   4.880m ± 1%        ~ (p=0.190 n=10)
geomean                               42.98m        45.80m        +6.56%

                                    │  before.txt  │              after.txt               │
                                    │     B/s      │     B/s       vs base                │
BackendBlockTraceQL/statuscode-11     341.4Mi ± 2%   338.8Mi ± 1%        ~ (p=0.052 n=10)
BackendBlockTraceQL/traceOrMatch-11   6.695Mi ± 8%   5.541Mi ± 9%  -17.24% (p=0.000 n=10)
BackendBlockTraceQL/complex-11        182.0Mi ± 5%   184.4Mi ± 1%        ~ (p=0.190 n=10)
geomean                               74.66Mi        70.22Mi        -5.94%

                                    │ before.txt  │              after.txt               │
                                    │  MB_io/op   │  MB_io/op    vs base                 │
BackendBlockTraceQL/statuscode-11      23.01 ± 0%    23.02 ± 0%  +0.04% (p=0.000 n=10)
BackendBlockTraceQL/traceOrMatch-11    1.753 ± 0%    1.766 ± 0%  +0.74% (p=0.000 n=10)
BackendBlockTraceQL/complex-11        943.7m ± 0%   943.7m ± 0%       ~ (p=1.000 n=10) ¹
geomean                                3.364         3.373       +0.26%
¹ all samples are equal

                                    │   before.txt   │              after.txt               │
                                    │      B/op      │     B/op       vs base               │
BackendBlockTraceQL/statuscode-11      31.19Mi ±  1%   31.29Mi ±  1%       ~ (p=0.436 n=10)
BackendBlockTraceQL/traceOrMatch-11   10.597Mi ± 17%   9.867Mi ± 35%       ~ (p=0.579 n=10)
BackendBlockTraceQL/complex-11         5.387Mi ±  4%   5.413Mi ±  2%       ~ (p=0.631 n=10)
geomean                                12.12Mi         11.87Mi        -2.09%

                                    │ before.txt  │             after.txt              │
                                    │  allocs/op  │  allocs/op   vs base               │
BackendBlockTraceQL/statuscode-11     378.4k ± 0%   378.6k ± 0%  +0.04% (p=0.000 n=10)
BackendBlockTraceQL/traceOrMatch-11   86.49k ± 1%   86.57k ± 1%       ~ (p=0.218 n=10)
BackendBlockTraceQL/complex-11        79.81k ± 0%   79.83k ± 0%  +0.02% (p=0.000 n=10)
geomean                               137.7k        137.8k       +0.05%

tempodb/encoding/vparquet4/testqt/floatattr.txt Outdated Show resolved Hide resolved
@ndk
Copy link
Contributor Author

ndk commented Feb 7, 2025

I cannot reproduce it. Could you tell me how you generated traces?

I scribbled such a Frankenstein monster
package vparquet4

import (
	"bytes"
	"context"
	"io"
	"math/rand"
	"os"
	"sort"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/require"

	"github.com/grafana/tempo/pkg/tempopb"
	"github.com/grafana/tempo/pkg/traceql"
	"github.com/grafana/tempo/pkg/util/test"
	"github.com/grafana/tempo/tempodb/backend"
	"github.com/grafana/tempo/tempodb/backend/local"
	"github.com/grafana/tempo/tempodb/encoding/common"

	v1_common "github.com/grafana/tempo/pkg/tempopb/common/v1"
	v1_resource "github.com/grafana/tempo/pkg/tempopb/resource/v1"
	v1_trace "github.com/grafana/tempo/pkg/tempopb/trace/v1"
)

type testTrace struct {
	traceID common.ID
	trace   *tempopb.Trace
}

type testIterator2 struct {
	traces []testTrace
}

func (i *testIterator2) Next(context.Context) (common.ID, *tempopb.Trace, error) {
	if len(i.traces) == 0 {
		return nil, nil, io.EOF
	}
	tr := i.traces[0]
	i.traces = i.traces[1:]
	return tr.traceID, tr.trace, nil
}

func (i *testIterator2) Close() {
}

func newTestTraces(traceCount int) []testTrace {
	traces := make([]testTrace, 0, traceCount)

	for i := 0; i < traceCount; i++ {
		traceID := test.ValidTraceID(nil)

		if i%2 == 0 {
			trace := MakeTraceWithCustomTags(traceID, "tempo-gateway", int64(i), true, true)
			traces = append(traces, testTrace{traceID: traceID, trace: trace})
		} else {
			trace := MakeTraceWithCustomTags(traceID, "megaservice", int64(i), false, false)
			traces = append(traces, testTrace{traceID: traceID, trace: trace})
		}
	}

	sort.Slice(traces, func(i, j int) bool {
		return bytes.Compare(traces[i].traceID, traces[j].traceID) == -1
	})

	return traces
}

var (
	blockID = uuid.MustParse("6757b4d9-8d6b-4984-a2d7-8ef6294ca503")
)

func TestGenerateBlocks(t *testing.T) {
	const (
		traceCount = 10000
	)

	blockDir, ok := os.LookupEnv("TRACEQL_BLOCKDIR")
	require.True(t, ok, "TRACEQL_BLOCKDIR env var must be set")

	rawR, rawW, _, err := local.New(&local.Config{
		Path: blockDir,
	})
	require.NoError(t, err)

	r := backend.NewReader(rawR)
	w := backend.NewWriter(rawW)
	ctx := context.Background()

	cfg := &common.BlockConfig{
		BloomFP:             0.01,
		BloomShardSizeBytes: 100 * 1024,
	}

	traces := newTestTraces(traceCount)
	iter := &testIterator2{traces: traces}
	meta := backend.NewBlockMeta(tenantID, blockID, VersionString, backend.EncNone, "")
	meta.TotalObjects = int64(len(iter.traces))
	_, err = CreateBlock(ctx, cfg, meta, iter, r, w)
	require.NoError(t, err)
}

func MakeTraceWithCustomTags(traceID []byte, service string, intValue int64, isError bool, setHTTP500 bool) *tempopb.Trace {
	now := time.Now()
	traceID = test.ValidTraceID(traceID)

	trace := &tempopb.Trace{
		ResourceSpans: make([]*v1_trace.ResourceSpans, 0),
	}

	var attributes []*v1_common.KeyValue

	attributes = append(attributes,
		&v1_common.KeyValue{
			Key: "stringTag",
			Value: &v1_common.AnyValue{
				Value: &v1_common.AnyValue_StringValue{StringValue: "value1"},
			},
		},
		&v1_common.KeyValue{
			Key: "intTag",
			Value: &v1_common.AnyValue{
				Value: &v1_common.AnyValue_IntValue{IntValue: intValue},
			},
		},
	)

	if setHTTP500 {
		attributes = append(attributes,
			&v1_common.KeyValue{
				Key: "http.status_code",
				Value: &v1_common.AnyValue{
					Value: &v1_common.AnyValue_IntValue{IntValue: 500},
				},
			},
		)
	}

	statusCode := v1_trace.Status_STATUS_CODE_OK
	statusMsg := "OK"
	if isError {
		statusCode = v1_trace.Status_STATUS_CODE_ERROR
		statusMsg = "Internal Error"
	}

	trace.ResourceSpans = append(trace.ResourceSpans, &v1_trace.ResourceSpans{
		Resource: &v1_resource.Resource{
			Attributes: []*v1_common.KeyValue{
				{
					Key: "service.name",
					Value: &v1_common.AnyValue{
						Value: &v1_common.AnyValue_StringValue{
							StringValue: service,
						},
					},
				},
				{
					Key: "other",
					Value: &v1_common.AnyValue{
						Value: &v1_common.AnyValue_StringValue{
							StringValue: "other-value",
						},
					},
				},
			},
		},
		ScopeSpans: []*v1_trace.ScopeSpans{
			{
				Spans: []*v1_trace.Span{
					{
						Name:         "test",
						TraceId:      traceID,
						SpanId:       make([]byte, 8),
						ParentSpanId: make([]byte, 8),
						Kind:         v1_trace.Span_SPAN_KIND_CLIENT,
						Status: &v1_trace.Status{
							Code:    statusCode,
							Message: statusMsg,
						},
						StartTimeUnixNano:      uint64(now.UnixNano()),
						EndTimeUnixNano:        uint64(now.Add(time.Second).UnixNano()),
						Attributes:             attributes,
						DroppedLinksCount:      rand.Uint32(),
						DroppedAttributesCount: rand.Uint32(),
					},
				},
			},
		},
	})
	return trace
}

func BenchmarkMixTraceQL(b *testing.B) {
	const query = "{ rootServiceName = `tempo-gateway` && (status = error || span.http.status_code = 500)}"

	blockDir, ok := os.LookupEnv("TRACEQL_BLOCKDIR")
	require.True(b, ok, "TRACEQL_BLOCKDIR env var must be set")

	ctx := context.TODO()

	r, _, _, err := local.New(&local.Config{Path: blockDir})
	require.NoError(b, err)

	rr := backend.NewReader(r)
	meta, err := rr.BlockMeta(ctx, blockID, tenantID)
	require.NoError(b, err)

	opts := common.DefaultSearchOptions()
	opts.StartPage = 3
	opts.TotalPages = 2

	block := newBackendBlock(meta, rr)
	_, _, err = block.openForSearch(ctx, opts)
	require.NoError(b, err)

	b.ResetTimer()
	bytesRead := 0

	for i := 0; i < b.N; i++ {
		e := traceql.NewEngine()

		resp, err := e.ExecuteSearch(ctx, &tempopb.SearchRequest{Query: query}, traceql.NewSpansetFetcherWrapper(func(ctx context.Context, req traceql.FetchSpansRequest) (traceql.FetchSpansResponse, error) {
			return block.Fetch(ctx, req, opts)
		}))
		require.NoError(b, err)
		require.NotNil(b, resp)

		// Read first 20 results (if any)
		bytesRead += int(resp.Metrics.InspectedBytes)
	}
	b.SetBytes(int64(bytesRead) / int64(b.N))
	b.ReportMetric(float64(bytesRead)/float64(b.N)/1000.0/1000.0, "MB_io/op")
}

generate

$ TRACEQL_BLOCKDIR=/workspaces/testblock go test -timeout 30m -run ^TestGenerateBlocks$ ./tempodb/encoding/vparquet4
after
TRACEQL_BLOCKDIR=/workspaces/testblock go test -benchmem -count 10 -run=^$ -bench ^BenchmarkMixTraceQL$ ./tempodb/encoding/vparquet4
BenchmarkMixTraceQL-16                 1        1239319102 ns/op         109.04 MB/s           135.1 MB_io/op   1026065080 B/op 17112874 allocs/op
BenchmarkMixTraceQL-16                 1        1228143811 ns/op         110.04 MB/s           135.1 MB_io/op   1026049224 B/op 17112718 allocs/op
BenchmarkMixTraceQL-16                 1        1228290452 ns/op         110.02 MB/s           135.1 MB_io/op   1026046936 B/op 17112705 allocs/op
BenchmarkMixTraceQL-16                 1        1327247874 ns/op         101.82 MB/s           135.1 MB_io/op   1026046680 B/op 17112701 allocs/op
BenchmarkMixTraceQL-16                 1        1258273596 ns/op         107.40 MB/s           135.1 MB_io/op   1026049528 B/op 17112715 allocs/op
BenchmarkMixTraceQL-16                 1        1240871840 ns/op         108.91 MB/s           135.1 MB_io/op   1026046776 B/op 17112703 allocs/op
BenchmarkMixTraceQL-16                 1        1236344582 ns/op         109.31 MB/s           135.1 MB_io/op   1026049048 B/op 17112715 allocs/op
BenchmarkMixTraceQL-16                 1        1240496677 ns/op         108.94 MB/s           135.1 MB_io/op   1026049208 B/op 17112717 allocs/op
BenchmarkMixTraceQL-16                 1        1223855401 ns/op         110.42 MB/s           135.1 MB_io/op   1026049128 B/op 17112717 allocs/op
BenchmarkMixTraceQL-16                 1        1253148161 ns/op         107.84 MB/s           135.1 MB_io/op   1026048200 B/op 17112706 allocs/op
before
TRACEQL_BLOCKDIR=/workspaces/testblock go test -benchmem -count 10 -run=^$ -bench ^BenchmarkMixTraceQL$ ./tempodb/encoding/vparquet4
BenchmarkMixTraceQL-16                 1        1192448670 ns/op         113.33 MB/s           135.1 MB_io/op   1026080664 B/op 17112732 allocs/op
BenchmarkMixTraceQL-16                 1        1228529159 ns/op         110.00 MB/s           135.1 MB_io/op   1026065144 B/op 17112580 allocs/op
BenchmarkMixTraceQL-16                 1        1221491370 ns/op         110.64 MB/s           135.1 MB_io/op   1026065448 B/op 17112579 allocs/op
BenchmarkMixTraceQL-16                 1        1203626980 ns/op         112.28 MB/s           135.1 MB_io/op   1026062664 B/op 17112565 allocs/op
BenchmarkMixTraceQL-16                 1        1216560603 ns/op         111.08 MB/s           135.1 MB_io/op   1026064640 B/op 17112574 allocs/op
BenchmarkMixTraceQL-16                 1        1209446194 ns/op         111.74 MB/s           135.1 MB_io/op   1026064312 B/op 17112571 allocs/op
BenchmarkMixTraceQL-16                 1        1249166815 ns/op         108.19 MB/s           135.1 MB_io/op   1026062632 B/op 17112564 allocs/op
BenchmarkMixTraceQL-16                 1        1220561026 ns/op         110.72 MB/s           135.1 MB_io/op   1026062552 B/op 17112563 allocs/op
BenchmarkMixTraceQL-16                 1        1245230127 ns/op         108.53 MB/s           135.1 MB_io/op   1026064712 B/op 17112575 allocs/op
BenchmarkMixTraceQL-16                 1        1291467390 ns/op         104.64 MB/s           135.1 MB_io/op   1026062632 B/op 17112565 allocs/op

UPD: I'm wondering how to check if a block has dedicated columns at all.

@ndk ndk force-pushed the mixed-type-attr-query branch from 8e1feb4 to 495c234 Compare February 8, 2025 11:46
@joe-elliott
Copy link
Member

I cannot reproduce it. Could you tell me how you generated traces?

we generally pull a block generated from internal tracing data which is why the benchmarks contain references to loki and tempo. these blocks generally cover a large range of organically created trace data.

nice work generating a large block. likely some pattern of data internally at Grafana is causing the regression. maybe you should write some float value http status codes and see what happens?

UPD: I'm wondering how to check if a block has dedicated columns at all.

the meta.json will list all dedicated columns in a block.

i do think there's a bug with the current implementation. fixing it may also resolve the regression. not sure. the query { span.http.status_code = 500 } is generating the following iterators:

LeftJoinIterator: 3: spanCollector(1)
required:
	UnionIterator: 3: %!s(<nil>)
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{500}
		SyncIterator: rs.list.element.Resource.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{500.000000})
optional:

I believe it should be this:

LeftJoinIterator: 3: spanCollector(1)
required:
	UnionIterator: 3: %!s(<nil>)
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{500}
		JoinIterator: 4: attributeCollector{}
			SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.Key : StringInPredicate{http.status_code}
			SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{500.000000}
optional:

I'm looking into the regression now.

@ndk
Copy link
Contributor Author

ndk commented Feb 10, 2025

LeftJoinIterator: 3: spanCollector(1)
required:
	UnionIterator: 3: %!s(<nil>)
		SyncIterator: rs.list.element.ss.list.element.Spans.list.element.HttpStatusCode : IntEqualPredicate{500}
		JoinIterator: 4: attributeCollector{}
			SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.Key : StringInPredicate{http.status_code}
			SyncIterator: rs.list.element.ss.list.element.Spans.list.element.Attrs.list.element.ValueDouble.list.element : FloatEqualPredicate{500.000000}
optional:

I'm looking into the regression now.

Yeah, I have the same hypothesis. I've just been looking into how to correctly attach filtering by key.

UPD: Done. Let this serve as a lesson for me: I should jot down my ideas and hypotheses, otherwise I'll forget them later. :)

@ndk ndk force-pushed the mixed-type-attr-query branch from 495c234 to ff2bc15 Compare February 10, 2025 20:18
@ndk
Copy link
Contributor Author

ndk commented Feb 10, 2025

nice work generating a large block. likely some pattern of data internally at Grafana is causing the regression. maybe you should write some float value http status codes and see what happens?

Yep, I also think so. Roaming around the code base I got an impression that dedicated columns aren't something by default. I feel I'm missing something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TraceQL: Ints can't be compared to floats
2 participants