简体   繁体   中英

What takes too long on this code?

Trying to solve another SO question , I came up with the following algorithm which I thought is quite optmized. However while running DotNetBenchmark on all solutions, I was very surprised that my code was running on a whopping average of 387 ms compared to the ~ 20-30 ms some of the other answers acheived.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
int CalcMe(string input) // I used Marc Gravel's input generation method
{
  var operands = input.Split(' ');
  var j = 1; // operators index

  var result = int.Parse(operands[0]); // output

  // i = numbers index
  for (int i = 2; i < operands.Length; i += 2)
  {
    switch (operands[j])
    {
      case "+":
        result += int.Parse(operands[i]);
        break;
      case "-":
        result -= int.Parse(operands[i]);
        break;
      case "*":
        result *= int.Parse(operands[i]);
        break;
      case "/":
        try
        {
          result /= int.Parse(operands[i]);
          break;
        }
        catch
        {
          break; // division by 0.
        }

      default:
        throw new Exception("Unknown Operator");
    }

    j += 2; // next operator
  }

  return result;
}

Just by extracting the String.Split() to the caller Main() method, I lowered the execution to 110 ms , but that still does not solve the mystery since all other answers handle the input directly.

I am just trying to understand to perhaps change my way of thinking toward optimizations. I couldn't see any keywords that I only use. switch , for and int.Parse() are pretty much on every other solution.

EDIT 1: Test input generation The input generation is copied form Marc answer on the original quetsion as below:

static string GenerateInput()
{
  Random rand = new Random(12345);
  StringBuilder input = new StringBuilder();
  string operators = "+-*/";
  var lastOperator = '+';
  for (int i = 0; i < 1000000; i++)
  {
    var @operator = operators[rand.Next(0, 4)];
    input.Append(rand.Next(lastOperator == '/' ? 1 : 0, 100) + " " + @operator + " ");
    lastOperator = @operator;
  }
  input.Append(rand.Next(0, 100));
  return input.ToString();
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]

Won't achieve almost anything here. Inlining is used when you want to tell to compiler to just copy and paste your code on multiple places to avoid unnecessary method invocations. And it's pretty damn smart to know when to do it on it's own in most of occasions.

var operands = input.Split(' ');

Causes the JIT to go through the whole string, do a search, split a string and fill the array, which can take a long time.

switch (operands[j])

Switching on strings can also have an impact since it has to call equals on cases. You'd want to use simple types in switch if you're looking at performance(char for example).

int.Parse

This actually does a bunch of allocations and even deals with unsafe code. You can see the code for parsing here:

https://referencesource.microsoft.com/#mscorlib/system/number.cs,698

Or if the link goes down:

[System.Security.SecuritySafeCritical]  // auto-generated
internal unsafe static Int32 ParseInt32(String s, NumberStyles style, NumberFormatInfo info) {

    Byte * numberBufferBytes = stackalloc Byte[NumberBuffer.NumberBufferBytes];
    NumberBuffer number = new NumberBuffer(numberBufferBytes);
    Int32 i = 0;

    StringToNumber(s, style, ref number, info, false);

    if ((style & NumberStyles.AllowHexSpecifier) != 0) {
        if (!HexNumberToInt32(ref number, ref i)) { 
            throw new OverflowException(Environment.GetResourceString("Overflow_Int32"));
        }
    }
    else {
        if (!NumberToInt32(ref number, ref i)) {
            throw new OverflowException(Environment.GetResourceString("Overflow_Int32"));
        }
    }
    return i;           
}

[System.Security.SecuritySafeCritical]  // auto-generated
private unsafe static void StringToNumber(String str, NumberStyles options, ref NumberBuffer number, NumberFormatInfo info, Boolean parseDecimal) {

    if (str == null) {
        throw new ArgumentNullException("String");
    }
    Contract.EndContractBlock();
    Contract.Assert(info != null, "");
    fixed (char* stringPointer = str) {
        char * p = stringPointer;
        if (!ParseNumber(ref p, options, ref number, null, info , parseDecimal) 
                || (p - stringPointer < str.Length && !TrailingZeros(str, (int)(p - stringPointer)))) {
            throw new FormatException(Environment.GetResourceString("Format_InvalidString"));
        }
    }
}

I think comparing of strings much more complicated than comparing of chars

Below the key difference

switch (operands[j])
{
    case "+":
        ...

switch (cOperator)
{
    case '+':
       ...

Interesting problem! I was interested in implementing this for myself, and checking what I can come up with, as well as how it compares to other implementations. I did it in F# but since both F# and C# are strongly-typed CLR languages, and the insights gained below are (arguably) independent of C#, I hope you'll agree that the following is not quite off-topic.

First I needed a few functions for creating a suitable expression string (adapted from your posting), measuring time, and running a bunch of functions with the generated string:

module Testbed =
    let private mkTestCase (n : int) =
        let next (r : System.Random) i = r.Next (0, i)
        let r = System.Random ()
        let s = System.Text.StringBuilder n
        let ops = "+-*/"
        (s.Append (next r 100), {1 .. n})
        ||> Seq.fold (fun s _ ->
            let nx = next r 100
            let op = ops.[next r (if nx = 0 then 3 else 4)]
            s.Append (" " + string op + " " + string nx))
        |> string

    let private stopwatch n f =
        let mutable r = Unchecked.defaultof<_>
        let sw = System.Diagnostics.Stopwatch ()
        sw.Start ()
        for i = 1 to n do r <- f ()
        sw.Stop ()
        (r, sw.ElapsedMilliseconds / int64 n)

    let runtests tests =
        let s, t = stopwatch 100 (fun () -> mkTestCase 1000000)
        stdout.Write ("MKTESTCASE\nTime: {0}ms\n", t)
        tests |> List.iter (fun (name : string, f) ->
            let r, t = stopwatch 100 (fun () -> f s)
            let w = "{0} ({1} chars)\nResult: {2}\nTime: {3}ms\n"
            stdout.Write (w, name, s.Length, r, t))

For a string of 1 million operations (around 4.9 million chars), the mkTestCase function ran in 317ms on my laptop.

Next I translated your function to F#:

module MethodsToTest =
    let calc_MBD1 (s : string) =
        let inline runop f a b =
            match f with
            | "+" -> a + b
            | "-" -> a - b
            | "*" -> a * b
            | "/" -> a / b
            | _ -> failwith "illegal op"
        let rec loop (ops : string []) r i j =
            if i >= ops.Length then r else
                let n = int ops.[i]
                loop ops (runop ops.[j] r n) (i + 2) (j + 2)
        let ops = s.Split ' '
        loop ops (int ops.[0]) 2 1

This ran in 488ms on my laptop.

Next I wanted to check if string matching is really that much slower than character matching:

    let calc_MBD2 (s : string) =
        let inline runop f a b =
            match f with
            | '+' -> a + b
            | '-' -> a - b
            | '*' -> a * b
            | '/' -> a / b
            | _ -> failwith "illegal op"
        let rec loop (ops : string []) r i j =
            if i >= ops.Length then r else
                let n = int ops.[i]
                loop ops (runop ops.[j].[0] r n) (i + 2) (j + 2)
        let ops = s.Split ' '
        loop ops (int ops.[0]) 2 1

Common wisdom would say that character matching should be significantly faster, given that it involves only a primitive comparison instead of calculating a hash, but the above ran in 482ms on my laptop, so the difference between primitive character comparison, and comparing hashes of strings of length 1 is almost negligible.

Lastly I checked whether hand-rolling the number parsing would provide a significant saving:

    let calc_MBD3 (s : string) =
        let inline getnum (c : char) = int c - 48
        let parse (s : string) =
            let rec ploop r i =
                if i >= s.Length then r else
                    let c = s.[i]
                    let n = if c >= '0' && c <= '9'
                            then 10 * r + getnum c else r
                    ploop n (i + 1)
            ploop 0 0
        let inline runop f a b =
            match f with
            | '+' -> a + b
            | '-' -> a - b
            | '*' -> a * b
            | '/' -> a / b
            | _ -> failwith "illegal op"
        let rec loop (ops : string []) r i j =
            if i >= ops.Length then r else
                let n = parse ops.[i]
                loop ops (runop ops.[j].[0] r n) (i + 2) (j + 2)
        let ops = s.Split ' '
        loop ops (parse ops.[0]) 2 1

This ran in 361ms on my laptop, so the saving is significant but the function is still an order of magnitude slower than my own creation (see below), leading to the conclusion that the initial string splitting takes the bulk of the time.

Just for comparison, I also translated the OP's function from the posting you referenced to F#:

    let calc_OP (s : string) =
        let operate r op x =
            match op with
            | '+' -> r + x
            | '-' -> r - x
            | '*' -> r * x
            | '/' -> r / x
            | _ -> failwith "illegal op"
        let rec loop c n r =
            if n = -1 then
                operate r s.[c + 1] (int (s.Substring (c + 3)))
            else
                operate r s.[c + 1] (int (s.Substring (c + 3, n - (c + 2))))
                |> loop n (s.IndexOf (' ', n + 4))
        let c = s.IndexOf ' '
        loop c (s.IndexOf (' ', c + 4)) (int (s.Substring (0, c)))

This ran in 238ms on my laptop, so using substrings is not as slow as splitting the string but still it is far from optimal.

Finally my own implementation of an expression interpreter, taking into account that the fastest way of processing is doing it manually character by character, iterating the string only once, and that heap allocation (by way of creating new objects, such as strings or arrays) should be avoided inside the loop as much as possible:

    let calc_Dumetrulo (s : string) =
        let inline getnum (c : char) = int c - 48
        let inline isnum c = c >= '0' && c <= '9'
        let inline isop c =
            c = '+' || c = '-' || c = '*' || c = '/'
        let inline runop f a b =
            match f with
            | '+' -> a + b
            | '-' -> a - b
            | '*' -> a * b
            | '/' -> a / b
            | _ -> failwith "illegal op"
        let rec parse i f a c =
            if i >= s.Length then
                if c = -1 then a else runop f a c
            else
                let k, j = s.[i], i + 1
                if isnum k then
                    let n = if c = -1 then 0 else c
                    parse j f a (10 * n + getnum k)
                elif isop k then parse j k a c
                elif c = -1 then parse j f a c
                else parse j f (runop f a c) -1
        parse 0 '+' 0 -1

This ran in a satisfactory 28ms on my laptop. You can express this the same way in C#, except for the tail-recursion, which should be expressed by a for or while loop:

    static int RunOp(char op, int a, int b)
    {
        switch (op)
        {
            case '+': return a + b;
            case '-': return a - b;
            case '*': return a * b;
            case '/': return a / b;
            default: throw new InvalidArgumentException("op");
        }
    }

    static int Calc_Dumetrulo(string s)
    {
        int a = 0, c = -1;
        char op = '+';
        for (int i = 0; i < s.Length; i++)
        {
            char k = s[i];
            if (k >= '0' && k <= '9')
                c = (c == -1 ? 0 : 10 * c) + ((int)k - 48);
            else if (k == '+' || k == '-' || k == '*' || k == '/')
                op = k;
            else if (c == -1) continue;
            else
            {
                a = RunOp(op, a, c);
                c = -1;
            }
        }
        if (c != -1) a = RunOp(op, a, c);
        return a;
    }

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM