Thử bắt nhanh tăng tốc mã của tôi?


1504

Tôi đã viết một số mã để kiểm tra tác động của việc thử bắt, nhưng thấy một số kết quả đáng ngạc nhiên.

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

Trên máy tính của tôi, điều này liên tục in ra một giá trị khoảng 0,96 ..

Khi tôi bọc vòng lặp for bên trong Fibo () bằng một khối thử bắt như thế này:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

Bây giờ nó liên tục in ra 0,69 ... - nó thực sự chạy nhanh hơn! Nhưng tại sao?

Lưu ý: Tôi đã biên dịch phần này bằng cấu hình Phát hành và chạy trực tiếp tệp EXE (bên ngoài Visual Studio).

EDIT: Phân tích xuất sắc của Jon Skeet cho thấy việc thử bắt bằng cách nào đó khiến CLR x86 sử dụng các thanh ghi CPU theo cách thuận lợi hơn trong trường hợp cụ thể này (và tôi nghĩ chúng ta vẫn chưa hiểu tại sao). Tôi đã xác nhận rằng Jon phát hiện ra rằng x64 CLR không có sự khác biệt này và nó nhanh hơn CLR x86. Tôi cũng đã thử nghiệm bằng cách sử dụng intcác loại bên trong phương thức Fibo thay vì các longloại và sau đó x86 CLR cũng nhanh như x64 CLR.


CẬP NHẬT: Có vẻ như vấn đề này đã được Roslyn khắc phục. Cùng một máy, cùng một phiên bản CLR - vấn đề vẫn như trên khi được biên dịch với VS 2013, nhưng vấn đề không còn nữa khi được biên dịch với VS 2015.


111
@Lloyd anh ấy cố gắng để có được câu trả lời cho câu hỏi của mình "nó thực sự chạy nhanh hơn! Nhưng tại sao?"
Andreas Niedermair

137
Vì vậy, bây giờ "Ngoại lệ nuốt" chuyển từ một thực tiễn xấu sang tối ưu hóa hiệu suất tốt: P
Luciano

2
Đây có phải là trong một bối cảnh số học không được kiểm tra hoặc kiểm tra?
Random832

7
@ taras.roshko: Mặc dù tôi không muốn thực hiện một dịch vụ của Eric, nhưng đây không thực sự là một câu hỏi C # - đó là một câu hỏi về trình biên dịch JIT. Khó khăn cuối cùng là tìm ra lý do tại sao JIT x86 không sử dụng nhiều thanh ghi mà không có thử / bắt như với khối thử / bắt.
Jon Skeet

63
Thật ngọt ngào, vì vậy nếu chúng ta làm tổ những lần thử này, chúng ta có thể đi nhanh hơn nữa phải không?
Chuck Pinkert

Câu trả lời:


1053

Một trong những kỹ sư Roslyn chuyên tìm hiểu tối ưu hóa việc sử dụng ngăn xếp đã xem xét điều này và báo cáo với tôi rằng dường như có vấn đề trong tương tác giữa cách trình biên dịch C # tạo ra các cửa hàng biến cục bộ và cách trình biên dịch JIT đăng ký lập lịch trong mã x86 tương ứng. Kết quả là tạo mã dưới mức tối ưu trên các tải và lưu trữ của người dân địa phương.

Vì một số lý do không rõ ràng đối với tất cả chúng ta, nên tránh đường dẫn tạo mã có vấn đề khi JITter biết rằng khối nằm trong khu vực được bảo vệ thử.

Điều này khá kỳ lạ. Chúng tôi sẽ theo dõi với nhóm JITter và xem liệu chúng tôi có thể đưa vào một lỗi để họ có thể khắc phục điều này không.

Ngoài ra, chúng tôi đang nghiên cứu các cải tiến cho Roslyn cho thuật toán của trình biên dịch C # và VB để xác định khi nào người dân địa phương có thể thực hiện "phù du" - nghĩa là chỉ cần đẩy và bật lên ngăn xếp, thay vì phân bổ một vị trí cụ thể trên ngăn xếp thời gian kích hoạt. Chúng tôi tin rằng JITter sẽ có thể thực hiện công việc phân bổ đăng ký tốt hơn và không có gì nếu chúng tôi đưa ra gợi ý tốt hơn về việc khi nào người dân địa phương có thể bị "chết" sớm hơn.

Cảm ơn bạn đã chú ý đến điều này và xin lỗi vì hành vi kỳ quặc này.


8
Tôi đã luôn tự hỏi tại sao trình biên dịch C # tạo ra nhiều địa phương ngoại lai như vậy. Ví dụ, các biểu thức khởi tạo mảng mới luôn tạo một cục bộ, nhưng không bao giờ cần thiết để tạo một cục bộ. Nếu nó cho phép JITter tạo mã hiệu suất cao hơn, có lẽ trình biên dịch C # nên cẩn thận hơn một chút về việc tạo các địa phương không cần thiết ...
Timwi

33
@Timwi: Hoàn toàn đúng. Trong mã không được tối ưu hóa, trình biên dịch tạo ra các địa phương không cần thiết với sự từ bỏ lớn vì chúng làm cho việc gỡ lỗi dễ dàng hơn. Trong mã tối ưu hóa tạm thời không cần thiết nên được loại bỏ nếu có thể. Thật không may, chúng tôi đã có nhiều lỗi trong những năm qua khi chúng tôi vô tình tối ưu hóa trình tối ưu hóa loại bỏ tạm thời. Kỹ sư đã nói ở trên hoàn toàn làm lại từ đầu tất cả các mã này cho Roslyn, và kết quả là chúng ta đã cải thiện nhiều hành vi được tối ưu hóa trong trình tạo mã Roslyn.
Eric Lippert

24
Đã có bất kỳ chuyển động về vấn đề này?
Robert Harvey

10
Có vẻ như Roslyn đã sửa nó.
Eren Ersönmez

56
Bạn đã bỏ lỡ cơ hội của mình để gọi nó là "lỗi JITter".
mbomb007

734

Chà, cái cách mà bạn sắp xếp thời gian có vẻ khá khó chịu với tôi. Sẽ hợp lý hơn nhiều nếu chỉ tính thời gian cho toàn bộ vòng lặp:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

Bằng cách đó, bạn không phải chịu sự chi phối của thời gian nhỏ, số học dấu phẩy động và lỗi tích lũy.

Đã thực hiện thay đổi đó, hãy xem liệu phiên bản "không bắt" có chậm hơn phiên bản "bắt" hay không.

EDIT: Được rồi, tôi đã thử nó - và tôi thấy kết quả tương tự. Rất kỳ quặc. Tôi tự hỏi liệu thử / bắt có vô hiệu hóa một số nội tuyến xấu hay không, nhưng sử dụng [MethodImpl(MethodImplOptions.NoInlining)]thay vào đó không giúp ...

Về cơ bản, bạn sẽ cần xem mã JITted được tối ưu hóa theo cordbg, tôi nghi ngờ ...

EDIT: Một vài thông tin nữa:

  • Đặt thử / bắt xung quanh chỉ n++;dòng vẫn cải thiện hiệu suất, nhưng không nhiều bằng đặt nó xung quanh toàn bộ khối
  • Nếu bạn bắt được một ngoại lệ cụ thể ( ArgumentExceptiontrong các thử nghiệm của tôi) thì nó vẫn nhanh
  • Nếu bạn in ngoại lệ trong khối bắt, nó vẫn nhanh
  • Nếu bạn nghĩ lại ngoại lệ trong khối bắt, nó sẽ chậm lại
  • Nếu bạn sử dụng khối cuối cùng thay vì khối bắt, nó sẽ chậm lại
  • Nếu bạn sử dụng khối cuối cùng cũng như khối bắt, nó sẽ nhanh

Kỳ dị...

EDIT: Được rồi, chúng tôi đã tháo gỡ ...

Đây là sử dụng trình biên dịch C # 2 và .NET 2 (32-bit) CLR, phân tách bằng mdbg (vì tôi không có cordbg trên máy của mình). Tôi vẫn thấy các hiệu ứng hiệu suất tương tự, ngay cả dưới trình gỡ lỗi. Phiên bản nhanh sử dụng một trykhối xung quanh mọi thứ giữa các khai báo biến và câu lệnh return, chỉ với một catch{}trình xử lý. Rõ ràng phiên bản chậm là như nhau ngoại trừ không có thử / bắt. Mã gọi (tức là Chính) giống nhau trong cả hai trường hợp và có cùng đại diện lắp ráp (vì vậy đây không phải là vấn đề nội tuyến).

Mã tháo rời cho phiên bản nhanh:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Mã tháo rời cho phiên bản chậm:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

Trong mỗi trường hợp, *chương trình trình gỡ lỗi nhập vào một "bước vào" đơn giản.

EDIT: Được rồi, giờ tôi đã xem qua mã và tôi nghĩ rằng tôi có thể thấy mỗi phiên bản hoạt động như thế nào ... và tôi tin rằng phiên bản chậm hơn chậm hơn vì nó sử dụng ít thanh ghi hơn và nhiều không gian ngăn xếp hơn. Đối với các giá trị nhỏ ncó thể nhanh hơn - nhưng khi vòng lặp chiếm phần lớn thời gian, nó sẽ chậm hơn.

Có thể khối thử / bắt buộc phải lưu và khôi phục nhiều thanh ghi hơn, do đó, JIT cũng sử dụng các thanh ghi cho vòng lặp ... điều này xảy ra để cải thiện hiệu suất tổng thể. Không rõ liệu đó có phải là một quyết định hợp lý cho JIT không sử dụng nhiều đăng ký trong mã "thông thường" hay không.

EDIT: Chỉ cần thử điều này trên máy x64 của tôi. CLR x64 nhanh hơn nhiều (nhanh hơn khoảng 3-4 lần) so với CLR x86 trên mã này và dưới x64, khối thử / bắt không tạo ra sự khác biệt đáng chú ý.


4
@GordonSimpson nhưng trong trường hợp chỉ bắt được một ngoại lệ cụ thể thì tất cả các ngoại lệ khác sẽ không bị bắt, do đó, bất kỳ chi phí nào liên quan đến giả thuyết của bạn cho việc không thử vẫn sẽ cần thiết.
Jon Hanna

45
Có vẻ như một sự khác biệt trong phân bổ đăng ký. Phiên bản nhanh quản lý để sử dụng esi,edicho một trong những thời gian dài thay vì ngăn xếp. Nó sử dụng ebxnhư bộ đếm, trong đó phiên bản chậm sử dụng esi.
Jeffrey Sax

13
@JeffreySax: Nó không chỉ thanh ghi được sử dụng nhưng có bao nhiêu. Phiên bản chậm sử dụng nhiều không gian ngăn xếp hơn, chạm vào ít thanh ghi hơn. Tôi không biết tại sao ...
Jon Skeet

2
Các khung ngoại lệ CLR được xử lý như thế nào về các thanh ghi và ngăn xếp? Có thể thiết lập một đã giải phóng một đăng ký để sử dụng bằng cách nào đó?
Random832

4
IIRC x64 có nhiều thanh ghi có sẵn hơn x86. Việc tăng tốc bạn đã thấy sẽ phù hợp với thử / bắt buộc sử dụng đăng ký bổ sung dưới x86.
Dan đang loay hoay bởi Firelight

116

Các đại diện của Jon cho thấy, sự khác biệt giữa hai phiên bản là phiên bản nhanh sử dụng một cặp thanh ghi ( esi,edi) để lưu trữ một trong các biến cục bộ nơi phiên bản chậm không có.

Trình biên dịch JIT đưa ra các giả định khác nhau về việc sử dụng thanh ghi cho mã có chứa khối thử bắt so với mã không. Điều này gây ra nó để thực hiện các lựa chọn phân bổ đăng ký khác nhau. Trong trường hợp này, điều này ủng hộ mã với khối thử bắt. Các mã khác nhau có thể dẫn đến hiệu ứng ngược lại, vì vậy tôi sẽ không tính đây là một kỹ thuật tăng tốc mục đích chung.

Cuối cùng, thật khó để biết mã nào sẽ chạy nhanh nhất. Một cái gì đó như phân bổ đăng ký và các yếu tố ảnh hưởng đến nó là các chi tiết triển khai ở mức độ thấp đến mức tôi không thấy bất kỳ kỹ thuật cụ thể nào có thể tạo ra mã nhanh hơn một cách đáng tin cậy.

Ví dụ, hãy xem xét hai phương pháp sau. Chúng được chuyển thể từ một ví dụ thực tế:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Một cái là phiên bản chung của cái kia. Thay thế loại chung bằng StructArraysẽ làm cho các phương thức giống hệt nhau. Bởi vì StructArraylà một loại giá trị, nó có phiên bản được biên dịch riêng của phương thức chung. Tuy nhiên, thời gian chạy thực tế dài hơn đáng kể so với phương pháp chuyên dụng, nhưng chỉ dành cho x86. Đối với x64, thời gian là khá giống nhau. Trong các trường hợp khác, tôi cũng đã quan sát thấy sự khác biệt cho x64.


6
Như đã nói ... bạn có thể buộc các lựa chọn phân bổ đăng ký khác nhau mà không cần sử dụng Thử / Bắt không? Hoặc là một thử nghiệm cho giả thuyết này hoặc là một nỗ lực chung để điều chỉnh tốc độ?
WernerCD

1
Có một số lý do tại sao trường hợp cụ thể này có thể khác nhau. Có lẽ đó là thử bắt. Có lẽ thực tế là các biến được sử dụng lại trong phạm vi bên trong. Dù lý do cụ thể là gì, đó là một chi tiết triển khai mà bạn không thể tin tưởng được bảo tồn ngay cả khi cùng một mã chính xác được gọi trong một chương trình khác.
Jeffrey Sax

4
@WernerCD Tôi muốn nói rằng C và C ++ có một từ khóa để gợi ý rằng (A) bị bỏ qua bởi nhiều trình biên dịch hiện đại và (B) đã quyết định không đưa vào C #, cho thấy đây không phải là thứ chúng tôi ' sẽ thấy trong bất kỳ cách trực tiếp hơn.
Jon Hanna

2
@WernerCD - Chỉ khi bạn tự viết bản lắp ráp
OrangeDog

72

Điều này trông giống như một trường hợp nội tuyến xấu đi. Trên lõi x86, jitter có thanh ghi ebx, edx, esi và edi có sẵn để lưu trữ mục đích chung cho các biến cục bộ. Thanh ghi ecx trở nên có sẵn trong một phương thức tĩnh, nó không phải lưu trữ cái này . Đăng ký eax thường là cần thiết để tính toán. Nhưng đây là các thanh ghi 32 bit, đối với các biến có kiểu dài thì phải sử dụng một cặp thanh ghi. Đó là edx: eax để tính toán và edi: ebx để lưu trữ.

Đó là những gì nổi bật trong việc tháo gỡ cho phiên bản chậm, cả edi và ebx đều không được sử dụng.

Khi jitter không thể tìm thấy đủ các thanh ghi để lưu trữ các biến cục bộ thì nó phải tạo mã để tải và lưu trữ chúng từ khung stack. Điều đó làm chậm mã, nó ngăn chặn tối ưu hóa bộ xử lý có tên là "đổi tên đăng ký", một thủ thuật tối ưu hóa lõi của bộ xử lý nội bộ sử dụng nhiều bản sao của một thanh ghi và cho phép thực hiện siêu vô hướng. Cho phép một số hướng dẫn chạy đồng thời, ngay cả khi chúng sử dụng cùng một thanh ghi. Không có đủ các thanh ghi là một vấn đề phổ biến trên các lõi x86, được giải quyết trong x64 có 8 thanh ghi phụ (r9 đến r15).

Jitter sẽ làm hết sức mình để áp dụng một tối ưu hóa tạo mã khác, nó sẽ cố gắng nội tuyến phương thức Fibo () của bạn. Nói cách khác, không thực hiện cuộc gọi đến phương thức mà tạo mã cho phương thức nội tuyến trong phương thức Main (). Tối ưu hóa khá quan trọng, đối với một, làm cho các thuộc tính của lớp C # miễn phí, mang lại cho chúng sự hoàn hảo của một trường. Nó tránh được chi phí thực hiện cuộc gọi phương thức và thiết lập khung ngăn xếp của nó, tiết kiệm một vài nano giây.

Có một số quy tắc xác định chính xác khi nào một phương thức có thể được nội tuyến. Chúng không được ghi lại chính xác nhưng đã được đề cập trong các bài đăng trên blog. Một quy tắc là nó sẽ không xảy ra khi cơ thể phương thức quá lớn. Điều đó đánh bại mức tăng từ nội tuyến, nó tạo ra quá nhiều mã không phù hợp với bộ đệm của lệnh L1. Một quy tắc cứng khác áp dụng ở đây là một phương thức sẽ không được nội tuyến khi nó chứa câu lệnh try / Catch. Bối cảnh đằng sau đó là một chi tiết triển khai các trường hợp ngoại lệ, chúng hỗ trợ cho Windows hỗ trợ tích hợp cho SEH (Xử lý ngoại lệ cấu trúc) dựa trên khung xếp chồng.

Một hành vi của thuật toán cấp phát đăng ký trong jitter có thể được suy ra khi chơi với mã này. Dường như nhận thức được khi jitter đang cố gắng nội tuyến một phương thức. Một quy tắc dường như chỉ sử dụng cặp thanh ghi edx: eax có thể được sử dụng cho mã nội tuyến có các biến cục bộ có kiểu dài. Nhưng không phải edi: ebx. Không còn nghi ngờ gì nữa vì điều đó sẽ quá bất lợi cho việc tạo mã cho phương thức gọi, cả edi và ebx đều là các thanh ghi lưu trữ quan trọng.

Vì vậy, bạn có được phiên bản nhanh vì jitter biết trước rằng phần thân phương thức chứa các câu lệnh try / Catch. Nó biết rằng nó không bao giờ có thể được nội tuyến nên dễ dàng sử dụng edi: ebx để lưu trữ cho biến dài. Bạn đã có phiên bản chậm vì jitter không biết trước rằng nội tuyến sẽ không hoạt động. Nó chỉ được tìm ra sau khi tạo mã cho thân phương thức.

Lỗ hổng sau đó là nó đã không quay lại và tạo lại mã cho phương thức. Đó là điều dễ hiểu, với những hạn chế về thời gian mà nó phải hoạt động.

Sự chậm lại này không xảy ra trên x64 bởi vì với một nó có thêm 8 thanh ghi. Đối với người khác vì nó có thể lưu trữ lâu chỉ trong một đăng ký (như rax). Và việc chậm lại không xảy ra khi bạn sử dụng int thay vì lâu vì jitter có tính linh hoạt cao hơn trong việc chọn các thanh ghi.


21

Tôi đã đưa ra nhận xét này vì tôi thực sự không chắc chắn rằng đây có thể là trường hợp, nhưng khi tôi nhớ lại thì đó không phải là một tuyên bố thử / ngoại trừ liên quan đến sửa đổi cách thức xử lý rác của cơ chế trình biên dịch hoạt động, trong đó nó xóa phân bổ bộ nhớ đối tượng theo cách đệ quy ra khỏi ngăn xếp. Có thể không có đối tượng nào bị xóa trong trường hợp này hoặc vòng lặp for có thể tạo thành một bao đóng mà cơ chế thu gom rác nhận ra đủ để thực thi một phương thức thu gom khác. Có lẽ là không, nhưng tôi nghĩ rằng nó đáng được đề cập vì tôi đã không thấy nó được thảo luận ở bất cứ nơi nào khác.

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.