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

Zero allocating StatsDPublisher #104

Merged
merged 24 commits into from
Oct 2, 2018

Conversation

dv00d00
Copy link
Contributor

@dv00d00 dv00d00 commented Sep 5, 2018

I've added a proof of concept implementation of buffer based statsd publisher.

Key points:

  • A concept of statsd message struct
  • Do not attempt formatting before sampling
  • Format directly into utf8
  • v2 transport interface uses ArraySegment which was in .net since 4.5
  • zero allocations in .net core 2.1 with pooled udp transport

Utf8FormatterBench

Method Mean Error StdDev Scaled Gen 0 Allocated
StringBased 609.8 ns 2.1574 ns 0.1219 ns 1.00 0.2203 696 B
BufferBased 279.9 ns 0.1432 ns 0.0081 ns 0.46 - 0 B

StatSendingBenchmark

Method Mean Error StdDev Gen 0 Allocated
RunUdp 288.132 us 6.1692 us 0.3486 us 0.4883 2944 B
RunUdpWithSampling 30.062 us 2.3620 us 0.1335 us 0.1221 467 B
RunIp 264.734 us 9.0817 us 0.5131 us 0.4883 2944 B
RunIpWithSampling 29.965 us 2.8734 us 0.1624 us 0.1221 468 B
RunV2 62.660 us 1.9967 us 0.1128 us - 0 B
RunV2WithSampling 6.438 us 0.3993 us 0.0226 us - 0 B

Dmitry Kushnir and others added 4 commits September 5, 2018 18:38
@dv00d00 dv00d00 requested a review from a team as a code owner September 5, 2018 20:55
@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 5, 2018

Open questions:

  • The fate of TimeStamp in gauges, see Unclear meaning of DateTime for Gauges #103
  • Max size of the encoded message. In theory, UDP datagrams could be as large as 8kb, in practice its usually 512 bytes max. Should we add an invariant on it?
  • Gauge does support sampling in statsd, should we expose it?
  • How to expose new functionality on a library level?

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 5, 2018

Due to the purpose of this pr as it is 3rd in a series.

Infrastructural code, as in this repository, must run as fast as possible. Which in .net realities does mean it should also allocate as less as possible. We got tools to do so and therefore should.

@@ -2,13 +2,16 @@
using BenchmarkDotNet.Attributes;
using JustEat.StatsD;
using JustEat.StatsD.EndpointLookups;
using JustEat.StatsD.V2;
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps the new publisher, transport types and namespaces should have a name other than V2, naming stuff is hard though.

Something that indicates it offers better perf / zero allocations.

Copy link
Member

Choose a reason for hiding this comment

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

+1, V2 is confusing given that the library is at version 3.

@slang25
Copy link
Member

slang25 commented Sep 6, 2018

This PR is fantastic work @dv00d00, zero allocations and it's not a breaking change! 💯

Copy link
Member

@martincostello martincostello left a comment

Choose a reason for hiding this comment

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

Overall this looks good, but there's some TODOs to deal with and I think this would be better done without all the "v2" duplication by making the existing concrete types support the "new" and "old" way.

I'd also probably argue that this would be v4, so we could finally make the pooled implementation the main implementation so we don't have to ship two, further reducing the duplication caused by making the pooled transport in 3.1 not be a breaking change.

@@ -24,5 +24,6 @@
<RepositoryType>git</RepositoryType>
<RepositoryUrl>$(PackageProjectUrl).git</RepositoryUrl>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>7.3</LangVersion>
Copy link
Member

Choose a reason for hiding this comment

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

var message = StatsDMessage.Timing(128, "bucket");
Check(message, 0.5, "prefix.bucket:128|ms|@0.5");
}

This comment was marked as resolved.

private static void Check(StatsDMessage message, double sampleRate, string expected)
{
Formatter.TryFormat(message, sampleRate, Buffer, out int written).ShouldBe(true);
var result = Encoding.UTF8.GetString(Buffer.AsSpan().Slice(0, written));
Copy link
Member

Choose a reason for hiding this comment

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

I think there's an AsSpan() overload that lets you do this in one:

var result = Encoding.UTF8.GetString(Buffer.AsSpan(0, written));


[assembly: ComVisible(false)]
[assembly: Guid("8f4ff09e-4130-4872-a50f-b290e9ccb04b")]

[assembly:InternalsVisibleTo("JustEat.StatsD.Tests")]
Copy link
Member

Choose a reason for hiding this comment

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

Spaces after colons.

case StatsDMessage.Kind.Timing:
{
if (!buffer.TryWriteLong(magnitudeIntegral)) return false;
if (!buffer.TryWriteBytes((byte)'|', (byte)'m', (byte)'s')) return false;
Copy link
Member

Choose a reason for hiding this comment

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

Constants?

if (!buffer.TryWriteDouble(msg.Magnitude)) return false;;
}

if (!buffer.TryWriteBytes((byte)'|', (byte)'g')) return false;
Copy link
Member

Choose a reason for hiding this comment

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

Constants?


if (sampleRate < 1.0 && sampleRate > 0.0)
{
if (!buffer.TryWriteBytes((byte)'|', (byte)'@')) return false;
Copy link
Member

Choose a reason for hiding this comment

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

Constants?

written = buffer.Written;
return true;
}

Copy link
Member

Choose a reason for hiding this comment

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

Extra new line.

_endpointSource = endPointSource ?? throw new ArgumentNullException(nameof(endPointSource));
}

public void Send(ArraySegment<byte> metric)
Copy link
Member

Choose a reason for hiding this comment

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

As per previous comments, we should be able to make the existing types implement both ways.

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 11, 2018

I am going to address V2 naming and structure in upcoming commits

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 12, 2018

Ok, I think I am done with refactoring and addressing issues, would appreciate another review

using Shouldly;
using Xunit;

#pragma warning disable xUnit1026 // Theory methods should use all of their parameters disabled to render test case name
Copy link
Member

Choose a reason for hiding this comment

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

Can you provide a screenshot of the before and after with this warning disabled?

}

public delegate IStatsDPublisher Factory(StatsDConfiguration config);
Copy link
Member

Choose a reason for hiding this comment

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

Could you not just use Func<StatsDConfiguration, IStatsDPublisher>?


public void Increment(long value, double sampleRate, params string[] buckets)
{
if (buckets == null)
Copy link
Member

Choose a reason for hiding this comment

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

Could also shortcut if buckets.Length == 0 to remove the need to allocate the enumerator.

{
// so we was not able to write to resized buffer
// that means there is a bug in formatter
throw new Exception("Utf8 Formatting Error. This is a bug." +
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about throwing vague exceptions to users going "oh, there's a bug".

image

<PackageReference Include="System.Memory" Version="4.5.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.0.0" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp2.1' ">
Copy link
Member

Choose a reason for hiding this comment

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

Could change this to Condition=" '$(TargetFramework)' != 'net451' " and then remove the need to reference it twice for both netstandard2.0 and netcoreapp2.1.


public StatsDPublisher(StatsDConfiguration configuration, IStatsDTransport transport)
public StatsDPublisher(StatsDConfiguration configuration, IStatsDTransport transport, bool preferBufferedTransport = false)
Copy link
Member

Choose a reason for hiding this comment

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

This is a binary breaking change - is that intentional?

}

public StatsDPublisher(StatsDConfiguration configuration)
public StatsDPublisher(StatsDConfiguration configuration, bool preferBufferedTransport = false)
Copy link
Member

Choose a reason for hiding this comment

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

As above.

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 13, 2018

There is currently no way to specify a constructor on StasDPublisher which would accept IBufferedStatsDTransport, it needs to have the separate signature, otherwise, compiler complains over Ambiguous invocation.

{
}

public StatsDPublisher(StatsDConfiguration configuration, bool preferBufferedTransport)
Copy link
Member

Choose a reason for hiding this comment

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

What's the reasoning behind making "better performance" opt-in? I think it would be better to add a constructor to accept the new interface, and have the existing two try use the better implementation, if possible, by default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As I have added new transport interface implementation to the existing transports, adding a constructor with signature accepting transport would result in a compilation error for the consumer.

I will make buffered implementation a default though.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure whether it's a good idea or not, but if you swapped the parameter order for the new one, it wouldn't be ambiguous to the compiler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The compiler will accept it without issues. 😸
Looks hacky though.

Other options I can think of:

  • Static factory methods on StatsDPublisher
  • Dedicated factory class
  • Dedicated transports per interface
  • Expose new public implementation of BufferdPublisher, String publisher will retain the old name

@@ -2,7 +2,7 @@

namespace JustEat.StatsD.Buffered
Copy link
Member

Choose a reason for hiding this comment

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

I think this would be better in JustEat.StatsD rather than JustEat.StatsD.Buffered to make it more discoverable.

Dmitry Kushnir added 2 commits September 13, 2018 21:36
@martincostello
Copy link
Member

So I think just two things left on this for me:

  1. Is there a way we can make the constructor for the publisher accept the new interface without creating ambiguity to the compiler? Maybe we could add a new factory type that could simplify the transport creation and hide the buffering/not away. Or maybe instead of the new bool argument we could add a new option to StatsDConfiguration that defaults to preferring buffering? Then we don't need to change the existing signatures at all.
  2. Bump the version to 3.2.0.


var transport = new PooledUdpTransport(endpointSource);

if (preferBufferedTransport)
Copy link
Member

Choose a reason for hiding this comment

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

As alluded to in my comment, maybe we could have if (configuration.PreferBufferedTransport) instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now it seems so obvious, thx @martincostello

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 14, 2018

pushed updates as requested

_onError = configuration.OnError;
switch (transport)
{
case IStatsDBufferedTransport transportV2 when configuration.PreferBufferedTransport:
Copy link
Member

Choose a reason for hiding this comment

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

Now we've dropped V2, maybe this should be called bufferedTransport?

private readonly StatsDMessageFormatter _formatter;
private readonly IStatsDTransport _transport;
private readonly Func<Exception, bool> _onError;
private readonly IStatsDPublisher _publisher;
Copy link
Member

Choose a reason for hiding this comment

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

As this class is now more of a wrapper, maybe call this _inner?

@martincostello
Copy link
Member

I think I'm basically happy with this now as a 3.2.0 release - great job! I'd like @AnthonySteele to review it as well though before we merge it.

Can we get one last set of performance test results from the tip of this branch?

@dv00d00
Copy link
Contributor Author

dv00d00 commented Sep 14, 2018

This run of benchmarks is taken from the different laptop. So could not be compared directly.

Utf8FormatterBenchmark

Method Mean Error StdDev Median Scaled ScaledSD Gen 0 Allocated
StringBased 876.9 ns 17.473 ns 35.69 ns 862.9 ns 1.00 0.00 0.4416 696 B
BufferBased 392.1 ns 7.869 ns 18.55 ns 389.7 ns 0.45 0.03 - 0 B

UdpStatSendingBenchmark

Method Mean Error StdDev Scaled Gen 0 Allocated
SendStatUdp 1,131.69 us 22.6319 us 58.0142 us 1.00 - 1184 B
SendStatPooledUdp 31.63 us 0.6324 us 1.1240 us 0.03 0.1831 384 B
SendStatPooledUdpBuffered 30.85 us 0.5901 us 0.6796 us 0.03 - 0 B
SendStatPooledUdpCoveredByAdapter 31.96 us 0.6388 us 1.6374 us 0.03 - 0 B

StatSendingBenchmark

Method Mean Error StdDev Median Gen 0 Allocated
RunUdp 2,503.119 us 49.9168 us 77.7144 us 2,485.259 us - 2944 B
RunUdpWithSampling 199.564 us 3.8886 us 5.8202 us 199.324 us 0.2441 597 B
RunIp 2,432.112 us 47.6688 us 71.3484 us 2,416.822 us - 2944 B
RunIpWithSampling 222.738 us 6.0002 us 17.1189 us 223.859 us 0.2441 601 B
RunBuffered 83.943 us 2.1686 us 6.3943 us 85.700 us - 0 B
RunBufferedWithSampling 6.422 us 0.1279 us 0.1617 us 6.432 us - 0 B

{
internal ref struct Buffer
{
public Buffer(Span<byte> source) : this()
Copy link
Member

Choose a reason for hiding this comment

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

Last minute thought, what's with : this(), isn't that redundant?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thx, good catch

@AnthonySteele
Copy link
Contributor

AnthonySteele commented Sep 20, 2018

As discussed with marticC

Look good, might need some trials under load.

Not sure if this will be V3.2 with no / minimal breaking changes; or 4.0 with other things tidied up as well.

When we can have breaking changes, we don't need both code paths: if we have two classes that work the same, and we know that they both work well in production loads, then we only need one of them (the faster / lower allocation one), there is no real user choice here. So that extra bool PreferBufferedTransport will fall away.

There still in a choice of Udp vs. Ip transports, we need both for different cases (e.g. AWS lambda). The idea of "a new factory type that could simplify the transport creation and hide the details" sounds worthwhile (though there is something like that in StatsDServiceCollectionExtensions ). So make the built-in, common cases trivial, and keep the existing more complex ways for users to plug in their own code overrides. I prefer enum options to bool in this case - adding new values to an enum is less disruptive. it's not user extensible but it's more flexible than bools.

@dv00d00
Copy link
Contributor Author

dv00d00 commented Oct 1, 2018

Any updates on this one @martincostello @AnthonySteele ?

@martincostello
Copy link
Member

I think we just need to decide whether this is a 3.2.0 or a 4.0.0 (as this PR changed the number, if it's going to be a 4.0.0 we should update it here before merging), then this is good to merge.

@AnthonySteele
Copy link
Contributor

If we go for a 4.0.0 then merge this and allow other changes before finalising it.

@martincostello
Copy link
Member

So which are we doing, 3.2.0 or 4.0.0?

@martincostello
Copy link
Member

Myself and @AnthonySteele discussed this offline, and we're going to release this as a 3.2.0 later today as something users can opt-in to if they want to use it. Then we'll make this and the changes from #81 the defaults and no longer an opt-in as part of a future 4.0.0 release.

@martincostello martincostello merged commit b1edccc into justeattakeaway:master Oct 2, 2018
@martincostello martincostello added this to the v3.2.0 milestone Oct 2, 2018
@martincostello
Copy link
Member

:shipit: https://www.nuget.org/packages/JustEat.StatsD/3.2.0

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.

4 participants