Tăng hiệu suất kỳ lạ trong điểm chuẩn đơn giản


97

Hôm qua, tôi đã tìm thấy một bài báo của Christoph Nahr có tiêu đề "Hiệu suất cấu trúc .NET" đã đánh giá một số ngôn ngữ (C ++, C #, Java, JavaScript) cho một phương pháp thêm hai cấu trúc điểm ( doublebộ giá trị).

Hóa ra, phiên bản C ++ mất khoảng 1000ms để thực thi (1e9 lần lặp), trong khi C # không thể dưới ~ 3000ms trên cùng một máy (và hoạt động thậm chí còn tệ hơn trong x64).

Để tự kiểm tra, tôi đã lấy mã C # (và đơn giản hóa một chút để chỉ gọi phương thức trong đó các tham số được truyền theo giá trị) và chạy nó trên máy i7-3610QM (tăng 3,1Ghz cho lõi đơn), RAM 8GB, Win8. 1, sử dụng .NET 4.5.2, bản dựng RELEASE 32-bit (x86 WoW64 vì hệ điều hành của tôi là 64-bit). Đây là phiên bản đơn giản hóa:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Với Pointđịnh nghĩa đơn giản là:

public struct Point 
{
    private readonly double _x, _y;

    public Point(double x, double y) { _x = x; _y = y; }

    public double X { get { return _x; } }

    public double Y { get { return _y; } }
}

Chạy nó cho kết quả tương tự như trong bài viết:

Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms

Quan sát kỳ lạ đầu tiên

Vì phương thức phải được nội tuyến, tôi tự hỏi mã sẽ hoạt động như thế nào nếu tôi xóa hoàn toàn các cấu trúc và chỉ đơn giản là liên kết toàn bộ mọi thứ lại với nhau:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    public static void Main()
    {
        // not using structs at all here
        double ax = 1, ay = 1, bx = 1, by = 1;

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
        {
            ax = ax + by;
            ay = ay + bx;
        }
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            ax, ay, sw.ElapsedMilliseconds);
    }
}

Và thực tế nhận được kết quả tương tự (thực sự chậm hơn 1% sau vài lần thử lại), có nghĩa là JIT-ter dường như đang làm tốt công việc tối ưu hóa tất cả các lệnh gọi hàm:

Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms

Điều đó cũng có nghĩa là điểm chuẩn dường như không đo lường bất kỳ structhiệu suất nào và thực sự dường như chỉ đo lường doublesố học cơ bản (sau khi mọi thứ khác được tối ưu hóa).

Những thứ kỳ lạ

Bây giờ đến phần kỳ lạ. Nếu tôi chỉ thêm một đồng hồ bấm giờ khác bên ngoài vòng lặp (vâng, tôi đã thu hẹp nó xuống bước điên rồ này sau nhiều lần thử lại), mã chạy nhanh hơn ba lần :

public static void Main()
{
    var outerSw = Stopwatch.StartNew();     // <-- added

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    outerSw.Stop();                         // <-- added
}

Result: x=1000000001 y=1000000001, Time elapsed: 961 ms

Thật là nực cười! Và nó không giống như Stopwatchviệc đưa ra kết quả sai cho tôi bởi vì tôi có thể thấy rõ rằng nó kết thúc sau một giây.

Bất cứ ai có thể cho tôi biết những gì có thể xảy ra ở đây?

(Cập nhật)

Đây là hai phương pháp trong cùng một chương trình, điều này cho thấy lý do không phải là JITting:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Test1();
        Test2();

        Console.WriteLine();

        Test1();
        Test2();
    }

    private static void Test1()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    private static void Test2()
    {
        var swOuter = Stopwatch.StartNew();

        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);

        swOuter.Stop();
    }
}

Đầu ra:

Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms

Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms

Đây là một pastebin. Bạn cần chạy nó dưới dạng bản phát hành 32 bit trên .NET 4.x (có một số kiểm tra trong mã để đảm bảo điều này).

(Cập nhật 4)

Sau nhận xét của @ usr về câu trả lời của @Hans, tôi đã kiểm tra cách tháo gỡ được tối ưu hóa cho cả hai phương pháp và chúng khá khác nhau:

Test1 ở bên trái, Test2 ở bên phải

Điều này dường như cho thấy sự khác biệt có thể là do trình biên dịch hoạt động hài hước trong trường hợp đầu tiên, chứ không phải là căn chỉnh trường kép?

Ngoài ra, nếu tôi thêm hai biến (tổng độ lệch 8 byte), tôi vẫn nhận được tốc độ tăng tương tự - và có vẻ như nó không còn liên quan đến việc căn chỉnh trường được Hans Passant đề cập:

// this is still fast?
private static void Test3()
{
    var magical_speed_booster_1 = "whatever";
    var magical_speed_booster_2 = "whatever";

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster_1);
    GC.KeepAlive(magical_speed_booster_2);
}

1
Bên cạnh JIT, nó cũng phụ thuộc vào sự tối ưu hóa của trình biên dịch, Ryujit mới nhất thực hiện nhiều tối ưu hóa hơn và thậm chí được giới thiệu hỗ trợ hướng dẫn SIMD hạn chế.
Felix K.

3
Jon Skeet đã tìm thấy một vấn đề hiệu suất với các trường chỉ đọc trong cấu trúc: Tối ưu hóa vi mô: sự kém hiệu quả đáng ngạc nhiên của các trường chỉ đọc . Thử đặt các trường riêng tư thành không đọc.
dbc

2
@dbc: Tôi đã thực hiện một bài kiểm tra chỉ với các doublebiến cục bộ, không có structs, vì vậy tôi đã loại trừ sự thiếu hiệu quả của bố cục / phương thức cấu trúc.
Groo

3
Có vẻ như chỉ xảy ra trên 32-bit, với RyuJIT, tôi nhận được 1600ms cả hai lần.
leppie

2
Tôi đã xem xét sự tháo gỡ của cả hai phương pháp. Không có gì thú vị để xem. Test1 tạo ra mã không hiệu quả mà không có lý do rõ ràng. Lỗi JIT hoặc theo thiết kế. Trong Test1, JIT tải và lưu trữ số nhân đôi cho mỗi lần lặp vào ngăn xếp. Điều này có thể để đảm bảo độ chính xác chính xác vì đơn vị float x86 sử dụng độ chính xác bên trong 80 bit. Tôi nhận thấy rằng bất kỳ lệnh gọi hàm không nội tuyến nào ở đầu hàm đều khiến nó hoạt động nhanh trở lại.
usr

Câu trả lời:


10

Cập nhật 4 giải thích vấn đề: trong trường hợp đầu tiên, JIT giữ các giá trị được tính toán ( a, b) trên ngăn xếp; trong trường hợp thứ hai, JIT giữ nó trong sổ đăng ký.

Trong thực tế, Test1hoạt động chậm vì Stopwatch. Tôi đã viết điểm chuẩn tối thiểu sau dựa trên BenchmarkDotNet :

[BenchmarkTask(platform: BenchmarkPlatform.X86)]
public class Jit_RegistersVsStack
{
    private const int IterationCount = 100001;

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithoutStopwatch()
    {
        double a = 1, b = 1;
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}", a);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithStopwatch()
    {
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // fadd        qword ptr [ebp-14h]
            // fstp        qword ptr [ebp-14h]
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithTwoStopwatches()
    {
        var outerSw = new Stopwatch();
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }
}

Kết quả trên máy tính của tôi:

BenchmarkDotNet=v0.7.7.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit  [RyuJIT]
Type=Jit_RegistersVsStack  Mode=Throughput  Platform=X86  Jit=HostJit  .NET=HostFramework

             Method |   AvrTime |    StdDev |       op/s |
------------------- |---------- |---------- |----------- |
   WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 |
      WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 |
 WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 |

Như chúng ta có thể thấy:

  • WithoutStopwatchhoạt động nhanh chóng (vì a = a + bsử dụng các thanh ghi)
  • WithStopwatchhoạt động chậm (vì a = a + bsử dụng ngăn xếp)
  • WithTwoStopwatcheshoạt động nhanh chóng trở lại (vì a = a + bsử dụng các thanh ghi)

Hành vi của JIT-x86 phụ thuộc vào số lượng lớn các điều kiện khác nhau. Vì một số lý do, đồng hồ bấm giờ đầu tiên buộc JIT-x86 sử dụng ngăn xếp và đồng hồ bấm giờ thứ hai cho phép nó sử dụng lại các thanh ghi.


Điều này không thực sự giải thích nguyên nhân. Nếu bạn kiểm tra các bài kiểm tra của tôi, có vẻ như bài kiểm tra có bổ sung Stopwatchthực sự chạy nhanh hơn . Nhưng nếu bạn hoán đổi thứ tự mà chúng được gọi trong Mainphương thức, thì phương thức kia sẽ được tối ưu hóa.
Groo

75

Có một cách rất đơn giản để luôn nhận được phiên bản "nhanh" của chương trình của bạn. Dự án> Thuộc tính> Xây dựng tab, bỏ chọn tùy chọn "Ưu tiên 32-bit", đảm bảo rằng lựa chọn mục tiêu Nền tảng là AnyCPU.

Bạn thực sự không thích 32-bit, tiếc là luôn được bật theo mặc định cho các dự án C #. Về mặt lịch sử, bộ công cụ Visual Studio hoạt động tốt hơn nhiều với các quy trình 32-bit, một vấn đề cũ mà Microsoft đã và đang giải quyết. Đã đến lúc loại bỏ tùy chọn đó, VS2015 đặc biệt giải quyết một số đoạn đường thực cuối cùng thành mã 64-bit bằng jitter x64 hoàn toàn mới và hỗ trợ phổ quát cho Chỉnh sửa + Tiếp tục.

Nói nhảm đủ rồi, những gì bạn phát hiện ra là tầm quan trọng của sự liên kết đối với các biến. Bộ vi xử lý quan tâm đến nó rất nhiều. Nếu một biến được căn chỉnh sai trong bộ nhớ thì bộ xử lý phải làm thêm công việc để xáo trộn các byte để chúng theo đúng thứ tự. Có hai vấn đề sai lệch rõ ràng, một là nơi các byte vẫn còn bên trong một dòng bộ đệm L1 duy nhất, tốn thêm một chu kỳ để chuyển chúng vào đúng vị trí. Và cái xấu nữa, cái bạn tìm thấy, trong đó một phần của các byte nằm trong một dòng bộ nhớ cache và một phần trong một dòng khác. Điều đó yêu cầu hai quyền truy cập bộ nhớ riêng biệt và dán chúng lại với nhau. Chậm gấp ba lần.

Các doublelongloại là những kẻ gây ra rắc rối trong quy trình 32-bit. Chúng có kích thước 64-bit. Và do đó có thể bị lệch 4, CLR chỉ có thể đảm bảo căn chỉnh 32 bit. Không phải là vấn đề trong quy trình 64-bit, tất cả các biến đều được đảm bảo căn chỉnh thành 8. Đây cũng là lý do cơ bản khiến ngôn ngữ C # không thể hứa hẹn chúng là nguyên tử . Và tại sao mảng đôi được phân bổ trong Khối đối tượng lớn khi chúng có hơn 1000 phần tử. LOH cung cấp đảm bảo căn chỉnh là 8. Và giải thích tại sao việc thêm một biến cục bộ lại giải quyết được vấn đề, một tham chiếu đối tượng là 4 byte nên nó đã di chuyển biến kép lên 4, bây giờ nó đã được căn chỉnh. Vô tình.

Một trình biên dịch C hoặc C ++ 32-bit thực hiện thêm công việc để đảm bảo rằng mã kép không thể bị lệch. Không hẳn là một vấn đề đơn giản để giải quyết, ngăn xếp có thể bị lệch khi một hàm được nhập vào, với điều kiện đảm bảo duy nhất là nó được căn chỉnh thành 4. Phần mở đầu của một hàm như vậy cần phải làm thêm công việc để căn chỉnh nó thành 8. Thủ thuật tương tự không hoạt động trong một chương trình được quản lý, trình thu gom rác quan tâm rất nhiều đến vị trí chính xác của một biến cục bộ trong bộ nhớ. Cần thiết để nó có thể phát hiện ra rằng một đối tượng trong GC heap vẫn được tham chiếu. Nó không thể xử lý đúng cách với một biến như vậy bị di chuyển bằng 4 vì ngăn xếp bị lệch khi phương thức được nhập.

Đây cũng là vấn đề cơ bản khiến .NET jitters không dễ dàng hỗ trợ các hướng dẫn SIMD. Chúng có các yêu cầu căn chỉnh mạnh hơn nhiều, loại mà bộ xử lý cũng không thể tự giải quyết được. SSE2 yêu cầu căn chỉnh là 16, AVX yêu cầu căn chỉnh là 32. Không thể lấy điều đó trong mã được quản lý.

Cuối cùng nhưng không kém phần quan trọng, cũng lưu ý rằng điều này làm cho hiệu suất của một chương trình C # chạy ở chế độ 32-bit rất khó đoán. Khi bạn truy cập một đôi hoặc dài được lưu trữ dưới dạng trường trong một đối tượng thì perf có thể thay đổi đáng kể khi bộ thu gom rác thu gọn đống. Di chuyển các đối tượng trong bộ nhớ, một trường như vậy giờ đây có thể đột nhiên bị sai lệch / căn chỉnh. Tất nhiên, rất ngẫu nhiên, có thể là một điều khá rắc rối :)

Chà, không có bản sửa lỗi đơn giản nhưng một, mã 64-bit là tương lai. Loại bỏ các rung động bắt buộc miễn là Microsoft không thay đổi mẫu dự án. Có thể là phiên bản tiếp theo khi họ cảm thấy tin tưởng hơn về Ryujit.


1
Không chắc chắn sự liên kết đóng vai trò như thế nào khi các biến kép có thể được (và đang ở trong Test2) được đăng ký. Test1 sử dụng ngăn xếp, Test2 thì không.
usr

2
Câu hỏi này đang thay đổi quá nhanh để tôi theo dõi. Bạn phải coi chừng chính bài kiểm tra ảnh hưởng đến kết quả của bài kiểm tra. Bạn cần đặt [MethodImpl (MethodImplOptions.NoInline)] vào các phương pháp thử nghiệm để so sánh táo với cam. Bây giờ bạn sẽ thấy rằng trình tối ưu hóa có thể giữ các biến trên ngăn xếp FPU trong cả hai trường hợp.
Hans Passant

4
Ồ, đó là sự thật. Tại sao việc căn chỉnh phương thức có bất kỳ tác động nào đến các hướng dẫn được tạo ?! Không được có bất kỳ sự khác biệt nào đối với phần thân của vòng lặp. Tất cả phải có trong sổ đăng ký. Phần mở đầu căn chỉnh phải không liên quan. Vẫn có vẻ như là một lỗi JIT.
usr

3
Tôi phải sửa lại câu trả lời một cách đáng kể. Tôi sẽ hoàn thành nó vào ngày mai.
Hans Passant

2
@HansPassant Bạn có định tìm hiểu các nguồn JIT không? Điều đó sẽ rất vui. Tại thời điểm này, tất cả những gì tôi biết là đó là một lỗi JIT ngẫu nhiên.
usr

5

Đã thu hẹp nó xuống một số nội dung (dường như chỉ ảnh hưởng đến thời gian chạy CLR 4.0 32 bit).

Lưu ý vị trí của các var f = Stopwatch.Frequency;tạo ra tất cả sự khác biệt.

Chậm (2700ms):

static void Test1()
{
  Point a = new Point(1, 1), b = new Point(1, 1);
  var f = Stopwatch.Frequency;

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Nhanh (800ms):

static void Test1()
{
  var f = Stopwatch.Frequency;
  Point a = new Point(1, 1), b = new Point(1, 1);

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Việc sửa đổi mã mà không cần chạm vào Stopwatchcũng thay đổi đáng kể tốc độ. Thay đổi chữ ký của phương thức thành Test1(bool warmup)và thêm một điều kiện trong Consoleđầu ra: if (!warmup) { Console.WriteLine(...); }cũng có tác dụng tương tự (tình cờ gặp phải điều này trong khi xây dựng các thử nghiệm của tôi để khắc phục sự cố).
InBetween

@InBetween: Tôi thấy, có gì đó hơi tanh. Cũng chỉ xảy ra trên cấu trúc.
leppie

4

Dường như có một số lỗi trong Jitter vì hành vi thậm chí còn tệ hơn. Hãy xem xét đoạn mã sau:

public static void Main()
{
    Test1(true);
    Test1(false);
    Console.ReadLine();
}

public static void Test1(bool warmup)
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    if (!warmup)
    {
        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Điều này sẽ chạy trong 900mili giây, giống như hộp đồng hồ bấm giờ bên ngoài. Tuy nhiên, nếu chúng ta loại bỏ if (!warmup)điều kiện, nó sẽ chạy trong 3000ms. Điều kỳ lạ hơn nữa là đoạn mã sau cũng sẽ chạy trong 900mili giây:

public static void Test1()
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
        0, 0, sw.ElapsedMilliseconds);
}

Lưu ý rằng tôi đã xóa a.Xvà các a.Ytham chiếu khỏi Consoleđầu ra.

Tôi không biết chuyện gì đang xảy ra, nhưng điều này có vẻ khá rắc rối với tôi và nó không liên quan đến việc có bên ngoài Stopwatchhay không, vấn đề có vẻ khái quát hơn một chút.


Khi bạn loại bỏ các cuộc gọi đến a.Xa.Y, trình biên dịch có thể miễn phí để tối ưu hóa hầu hết mọi thứ bên trong vòng lặp, vì kết quả của hoạt động không được sử dụng.
Groo

@Groo: vâng, điều đó có vẻ hợp lý nhưng không phải khi bạn tính đến những hành vi kỳ lạ khác mà chúng tôi đang thấy. Việc loại bỏ a.Xa.Ykhông làm cho nó hoạt động nhanh hơn bất kỳ khi bạn bao gồm if (!warmup)điều kiện hoặc OP outerSw, có nghĩa là nó không tối ưu hóa bất cứ thứ gì đi, nó chỉ loại bỏ bất kỳ lỗi nào đang làm cho mã chạy ở tốc độ dưới mức tối ưu ( 3000ms thay vì 900ms).
InBetween

2
Oh, ok, tôi nghĩ sự cải thiện tốc độ đã xảy ra khi warmuplà đúng, nhưng trong trường hợp đó dòng thậm chí không được in, vì vậy trường hợp nó không được in thực sự tài liệu tham khảo a. Tuy nhiên, tôi muốn đảm bảo rằng tôi luôn tham khảo kết quả tính toán ở đâu đó gần cuối phương pháp, bất cứ khi nào tôi đo điểm chuẩn.
Groo
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.