2

In a part of my code that I call tens of thousands of times per second, I have several asserts that go like this:

Assert(value1 > value2, $"{value1} was not greater than {value2}");

I am getting poor performance because it constantly calls String.Format() on my error string, $"{value1} was not greater than {value2}". I ran profilers to verify this: String.Format() on these calls is taking 50% of my total time.

I'd like to design my own assert function that doesn't need to format the error string until the assertion is actually triggered. The simplest way would, of course, be this:

if (value1 > value2) 
{
    Debug.LogError($"{value1} was not greater than {value2}");
}

But I was hoping I could do it in a one-liner that I can easily remove in release builds. With a function, I can use the ConditionalAttribute to make them only active on debug builds.

Maybe there's some trick with expressions that'll let me defer creating the error string?

lifeformed
  • 525
  • 4
  • 16
  • 4
    Assert(bool result, Func error) where if !result Debug.LogError(error()) (assuming these aren't eagerly evaluated, but you can try it out) –  Dec 11 '18 at 21:44
  • 1
    Are you absolutely positive this is your performance bottleneck? Because the processing for this seems trivial – maccettura Dec 11 '18 at 21:44
  • 3
    @maccettura Never underestimate the power of overriding `ToString` :) – Heretic Monkey Dec 11 '18 at 21:45
  • 2
    I think you can defer formatting if you make the 2nd parameter FormattableString instead of String – Leo Bartkus Dec 11 '18 at 21:45
  • 1
    i very much doubt that its causing you a performance problem. – pm100 Dec 11 '18 at 21:47
  • @maccettura @pm100 Yup, I ran some tests. Almost 50% of my processing time is spent on `String.Format()` on those calls. Honestly, it is surprising for me too. – lifeformed Dec 11 '18 at 21:48
  • 1
    *in a one-liner that I can easily remove in release builds* - If you want code to be omitted automatically in release builds, you may be interested in [#if DEBUG vs. Conditional(“DEBUG”)](https://stackoverflow.com/q/3788605/1115360). – Andrew Morton Dec 11 '18 at 21:48
  • if 50% of cpu time is spent in string.format then you must basically have no other code running. I have the same trace/ log system and with string.format and no clever magic. No problems for me – pm100 Dec 11 '18 at 22:21
  • @pm100 It doesn't mean there isn't other code, it just means that other code, [does a comparable amount of work to what it takes to generate these strings](https://stackoverflow.com/questions/53732738/c-designing-a-faster-assert-function-that-only-formats-the-error-string-when-t#comment94319461_53732738), which could be rather considerable. – Servy Dec 11 '18 at 22:28
  • @pm100: if this code is inside a property getter which gets called 20,000 times per second, I believe the performance effects might not be so negligible. – vgru Dec 11 '18 at 22:31
  • doing this inside 20k loop is clearly not a good thing. YOU need conditonal compile stuf then :-) – pm100 Dec 11 '18 at 22:32
  • something like this is likely to be a bit faster since the middle part of the string can be interned: `value1 + " was not greater than " + value2` ... honestly though this comes down to how your error logger is implemented. If it can output 3 strings instead of 1 combined it will be a lot faster I think – Joe Phillips Dec 11 '18 at 23:38

3 Answers3

2

As always, premature optimization is the root of all evil. It's also unclear from your question why you wouldn't simply use the [Conditional("DEBUG")] attribute -- if you want to remove logs in release builds, this is the way to go, period.

(Update) From .NET 4.6 onward, you can specify the parameter as FormattableString, as mentioned in the comments below your question:

void Assert(bool condition, FormattableString message)
{
     if (condition)
        DoSomething(message.ToString());
}

// this will actually create an instance of FormattableString
// under the hood, which will not be formatted until you 
// call FormattableString.ToString() inside the method

Assert(value1 > value2, $"{value1} was not greater than {value2}");

If the condition is really rarely fulfilled, and you want to avoid creating the FormattableString instance too, and you are willing to go yet another crazy step forward, you could create a whole bunch of generic methods similar to:

void Assert<T1>(bool condition, string format, T1 a);
void Assert<T1, T2>(bool condition, string format, T1 a, T2 b);
void Assert<T1, T2, T3>(bool condition, string format, T1 a, T2 b, T3 c);
...

// if value1 is `int`, and value2 is `double`, this will resolve
// into Assert<int, double>(bool, string, int, double) -- all parameters
// are simply passed into the method, and nothing is instantiated

Assert(value1 > value2, "{0} was not greater than {1}", value1, value2);

Since these will get compiled into methods accepting actual, strongly typed parameters, there won't be any additional heap instantiations this way, so you have practically no performance burden if the condition is not fulfilled.

A quick benchmark shows that the first change (using FormattableString instead of string) is ~1.6x faster when condition is fulfilled 50% of the time (random.NextDouble() > 0.5). The approach with generic methods is ~2x faster this way:

  • 5,000,000 iterations with logging in 50% cases:
    • string interpolation: 5 s
    • FormattableString: 3 s
    • generic methods: 2.5 s

If the condition is fulfilled rarely (5% of the time), first optimization resulted in ~8x speedup, while the generic approach was ~14x faster, with far less allocations:

  • 5,000,000 iterations with logging in 5% cases:
    • string interpolation: 3.5 s
    • FormattableString: 0.5 s
    • generic methods: 0.25 s
vgru
  • 49,838
  • 16
  • 120
  • 201
  • Thanks. Do you have a repl.it link with your tests you could share? I want to try out a comparison against using a lambda as an argument for a `Func` parameter. ie: `Assert(value1 > value2, () => $"{value1} was not greater than {value2}");` – lifeformed Dec 12 '18 at 00:08
  • 1
    @lifeformed: [Here is a quick example](https://repl.it/@VedranGrudenic/Deferring-string-interpolation?language=csharp). I don't use repl.it so I don't know if there is a way to nuget an actual benchmark library? I just used a stopwatch to measure, and you should probably get more accurate results in a release build on your local machine. I'd say that switching to `FormattableString` is perhaps the best approach, you get a pretty good improvement without having to change the way you invoke these methods. – vgru Dec 12 '18 at 00:35
  • Thanks! Yes, FormattableString looks to be the best compromise. – lifeformed Dec 12 '18 at 04:59
  • 1
    Note that the performance of the built-in assert should be a lot better in C# 10 per https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/debug-assert-conditional-evaluation This also allows you to design your own "zero allocations unless condition true" logging assertions. – Mads Ravn Jan 31 '22 at 20:19
  • @MadsRavn: thanks, that's right, they really went out of their way in C#10 to heavily optimize this, I forgot about this answer so I should probably update it. – vgru Jan 31 '22 at 21:49
  • @Groo Cheers. I was actually looking for the new feature and couldn't remember the name and googling "assert formattablestring" led me here, so it would be nice with an updated reference :) – Mads Ravn Feb 01 '22 at 15:55
1

If you don't have a specific requirement to use the System.Diganostics functions I recommend you use a logging library instead.

Logging libraries provide this features and give you the option to log into files and choose what level of information you want to get in the log. Also you can change the log level while the application is running by changing the configuration.

Some popular ones are ones are:

Aleš Doganoc
  • 11,568
  • 24
  • 40
1

You can significantly improve performance by using parameterized arguments instead of string interpolation.

static void Main(string[] args)
{
   System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
   sw.Start();
   for (int i = 0; i < 10000000; i++)
   {
      Assert(i % 1000000 != 700, $"Oops on {i}");
   }
   Console.WriteLine("{0}", sw.Elapsed);
}

[System.Diagnostics.Conditional("DEBUG")]
static void Assert(bool cond, string msg, params object[] args)
{
   if (!cond)
      Console.WriteLine(msg, args);
}

This code outputs 00:00:01.7812783 on my system, indicating that it took about 1.8 seconds to run.

If you change the Assert line to this:

Assert(i % 1000000 != 700, "Oops on {0}", i);

You get the same behavior, but the performance is better, reporting: 00:00:00.1127145

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146