Câu hỏi Cố gắng tăng tốc mã của tôi?


Tôi đã viết một số mã để thử nghiệm tác động của try-catch, 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 luôn in ra một giá trị khoảng 0,96 ..

Khi tôi quấn vòng lặp for bên trong Fibo () với một khối try-catch 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ó luôn 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 điều này bằng cách sử dụ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).

CHỈNH SỬA: Jon Skeet's Xuất sắc phân tích cho thấy try-catch bằng cách nào đó khiến cho CLR x86 sử dụng thanh ghi CPU một 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 phát hiện của Jon rằng x64 CLR không có sự khác biệt này, và rằng nó đã nhanh hơn so với CLR x86. Tôi cũng đã thử nghiệm sử dụng int các loại bên trong phương thức Fibo thay vì long các loại, và sau đó x86 CLR là như nhau nhanh như x64 CLR.


CẬP NHẬT: Dường như vấn đề này đã được cố định bởi Roslyn. 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 đề sẽ biến mất khi được biên soạn với VS 2015.


1342
2018-01-19 15:10


gốc


@Lloyd anh ta cố gắng để có được một câu trả lời về câu hỏi của mình "nó thực sự chạy nhanh hơn! Nhưng tại sao?" - Andreas Niedermair
Vì vậy, bây giờ "Nuốt ngoại lệ" được chuyển từ thực tiễn không tốt sang tối ưu hóa hiệu suất tốt: P - Luciano
Đây có phải là một ngữ cảnh số học không được kiểm tra hoặc kiểm tra không? - Random832
@ taras.roshko: Trong khi tôi không muốn làm Eric một vụ bất bình, đây thực sự không phải là câu hỏi C # - đó là câu hỏi về trình biên dịch JIT. Khó khăn cuối cùng là làm việc tại sao x86 JIT không sử dụng nhiều thanh ghi mà không có try / catch như nó với khối try / catch. - Jon Skeet
Ngọt ngào, vì vậy nếu chúng ta làm tổ những thử bắt chúng ta có thể đi thậm chí còn nhanh hơn phải không? - Chuck Pinkert


Các câu trả lời:


Một trong những Roslyn các kỹ sư chuyên hiểu về tối ưu hóa việc sử dụng stack đã 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 kho biến cục bộ và cách JIT trình biên dịch đăng ký lập kế hoạch trong mã x86 tương ứng. Kết quả là tạo mã tối ưu trên các tải và các cửa hàng của người dân địa phương.

Đối với một số lý do không rõ ràng cho tất cả chúng ta, con đường tạo mã có vấn đề được tránh khi JITter biết rằng khối nằm trong vùng được bảo vệ.

Điều này khá lạ. Chúng tôi sẽ liên hệ với nhóm JITter và xem liệu chúng tôi có thể nhận được lỗi được nhập để họ có thể sửa lỗi này hay không.

Ngoài ra, chúng tôi đang thực hiện các cải tiến cho Roslyn với thuật toán C # và VB để xác định khi nào người dân địa phương có thể được tạo thành "tạm thời" - nghĩa là, chỉ cần đẩy và xuất hiện trê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 một công việc tốt hơn về phân bổ đăng ký và không biết nếu chúng tôi đưa ra gợi ý tốt hơn về thời điểm người dân địa phương có thể bị "chết" trước đó.

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


928
2018-01-20 20:14



Tôi đã luôn tự hỏi tại sao trình biên dịch C # lại tạo ra rất nhiều người dân địa phương không liên quan. Ví dụ, các biểu thức khởi tạo mảng mới luôn tạo ra một cục bộ, nhưng không bao giờ cần thiết để tạo ra một cục bộ. Nếu nó cho phép JITter tạo ra mã có 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 ra các locals không cần thiết ... - Timwi
@Timwi: Tuyệt đối. Trong mã không được tối ưu hóa, trình biên dịch tạo ra những người dân địa phương không cần thiết với sự từ bỏ tuyệt vời bởi vì họ làm cho việc gỡ lỗi dễ dàng hơn. Trong tối ưu hóa mã thời gian 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 mà chúng tôi vô tình bỏ tối ưu hóa trình tối ưu hóa loại bỏ tạm thời. Các kỹ sư nói trên là hoàn toàn làm lại từ đầu tất cả các mã này cho Roslyn, và chúng ta nên kết quả là có nhiều cải thiện hành vi tối ưu hóa trong máy phát điện mã Roslyn. - Eric Lippert
Đã bao giờ có bất kỳ chuyển động nào về vấn đề này? - Robert Harvey♦
@RobertHarvey: Eric Lippert hiện không làm việc trong MS ... Tôi không nghĩ anh ấy có thể đưa ra tiến bộ mới nhất .. - Danny Chen
Bạn đã bỏ lỡ cơ hội để gọi nó là "lỗi JITter". - mbomb007


Vâng, cách bạn đang thời gian mọi thứ trông khá khó chịu với tôi. Nó sẽ hợp lý hơn nhiều với thời gian 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 ở lòng thương xót của timings nhỏ, số học dấu chấm động và lỗi tích lũy.

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

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

Về cơ bản bạn sẽ cần phải nhìn vào mã JITted tối ưu theo dây rốn, tôi nghi ngờ ...

EDIT: Một vài bit thông tin:

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

Kỳ dị...

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

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

Đã tháo rời mã 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

Đã tháo rời mã 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 * cho thấy nơi trình gỡ lỗi được nhập trong bước "bước vào" đơn giản.

EDIT: Được rồi, bây giờ tôi đã xem xét các mã và tôi nghĩ rằng tôi có thể xem làm thế nào mỗi phiên bản hoạt động ... và tôi tin rằng phiên bản chậm hơn là chậm hơn bởi vì nó sử dụng ít đăng ký và nhiều không gian ngăn xếp. Đối với các giá trị nhỏ của n điều đó có 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 try / catch lực lượng đăng ký nhiều hơn để được lưu và phục hồi, do đó, JIT cũng sử dụng các đăng ký đó 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 đó là một quyết định hợp lý cho JIT đối với không phải sử dụng nhiều thanh ghi trong mã "bình thường".

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


702
2018-01-19 15:15



@GordonSimpson nhưng trong trường hợp chỉ có một ngoại lệ cụ thể bị bắt thì tất cả các trường hợp ngoại lệ khác sẽ không bị bắt, do đó, bất cứ chi phí nào liên quan đến giả thuyết của bạn vì không cố gắng vẫn sẽ là cần thiết. - Jon Hanna
Nó trông giống như một sự khác biệt trong phân bổ đăng ký. Phiên bản nhanh chóng quản lý để sử dụng esi,edi cho một trong những thời gian dài thay vì ngăn xếp. Nó sử dụng ebx làm bộ đếm, nơi phiên bản chậm sử dụng esi. - Jeffrey Sax
@ JeffreySax: Không chỉ cái nào đăng ký được sử dụng nhưng 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
IIRC x64 có nhiều thanh ghi hơn x86. Tốc độ bạn thấy sẽ phù hợp với thử / bắt buộc phải sử dụng đăng ký bổ sung theo x86. - Dan Neely
@JonSkeet Tôi đã bỏ phiếu sớm hơn nhiều trong ngày khi nó trông như thế này đã không trả lời câu hỏi (nó chỉ đưa ra một gợi ý và xác nhận kinh nghiệm OP, mà dường như giống như một bình luận). Downvote loại bỏ ngay bây giờ (chuyển thành upvote thực sự), vì điều này có một số giải thích rất chính đáng =) - jadarnel27


Sự phân tách 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ộ trong đó phiên bản chậm không.

Trình biên dịch JIT đưa ra các giả định khác nhau liên quan đến việc sử dụng đăng ký mã có chứa khối try-catch so với mã không. Điều này làm cho nó để thực hiện lựa chọn phân bổ đăng ký khác nhau. Trong trường hợp này, điều này ưu tiên mã với khối try-catch. 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 đa năng.

Cuối cùng, rất khó để biết mã nào sẽ chạy nhanh nhất. Một cái gì đó giống như phân bổ đăng ký và các yếu tố ảnh hưởng đến nó là những chi tiết thực hiện cấp thấp như vậy mà tôi không thấy cách 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 đây. 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 là phiên bản chung của cái kia. Thay thế loại chung với StructArray sẽ làm cho các phương pháp giống hệt nhau. Bởi vì StructArray là một kiểu giá trị, nó nhận phiên bản được biên dịch riêng của phương thức generic. Tuy nhiên, thời gian chạy thực tế là dài hơn đáng kể so với các phương pháp chuyên ngành, nhưng chỉ 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 sự khác biệt đối với x64.


110
2018-01-19 18:27



Với điều đó đang được 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 Try / Catch 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 để tinh chỉnh tốc độ? - WernerCD
Có một số lý do tại sao trường hợp cụ thể này có thể khác. Có lẽ đó là thử-catch. Có thể 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ì, nó là một chi tiết thực hiện mà bạn không thể đếm được để đượ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
@ WernerCD Tôi muốn nói rằng C và C ++ có từ khóa cho thấy rằng (A) bị bỏ qua bởi nhiều trình biên dịch hiện đại và (B) nó đã được quyết định không đưa vào C #, gợi ý rằng đây không phải là thứ chúng ta ' sẽ thấy theo bất kỳ cách nào trực tiếp hơn. - Jon Hanna
@ WernerCD - Chỉ khi bạn viết bản thân hội đồng - OrangeDog


Điều này có vẻ 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 của các biến cục bộ. Thanh ghi ecx có sẵn trong một phương thức tĩnh, nó không phải lưu trữ điều này. Thanh ghi eax thường là cần thiết để tính toán. Nhưng đây là những thanh ghi 32 bit, đối với các biến kiểu dài nó 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ỡ phiên bản chậm, cả edi lẫn ebx đều không được sử dụng.

Khi jitter không thể tìm thấy đủ đăng ký để lưu trữ các biến địa phương thì nó phải tạo mã để tải và lưu trữ chúng từ khung ngăn xếp. Điều đó làm chậm mã, nó ngăn chặn tối ưu hóa bộ xử lý có tên là "đăng ký đổi tên", một mẹo tối ưu hóa lõi 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ó đủ đăng ký là một vấn đề phổ biến trên lõi x86, được giải quyết trong x64 có 8 thanh ghi bổ sung (r9 đến r15).

Các jitter sẽ làm hết sức mình để áp dụng một tối ưu hóa thế hệ mã khác, nó sẽ cố gắng inline 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 nhưng 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 một lớp C # miễn phí, tạo cho chúng sự hoàn hảo của một trường. Nó tránh được phí tổn của việ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 gạch chân. Họ không chính xác tài liệu nhưng đã được đề cập trong bài viết blog. Một quy tắc là nó sẽ không xảy ra khi thân phương thức quá lớn. Điều đó đánh bại được lợi ích từ nội tuyến, nó tạo ra quá nhiều mã không phù hợp với bộ nhớ cache lệnh L1. Một quy tắc cứng khác áp dụng ở đây là phương thức sẽ không được gạch chân khi nó chứa câu lệnh try / catch. Bối cảnh đằng sau đó là một chi tiết thực hiện của các trường hợp ngoại lệ, chúng quay trở lại hỗ trợ tích hợp sẵn của Windows 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 phân bổ đăng ký trong jitter có thể được suy ra từ việc chơi với mã này. Nó xuất hiện để được nhận thức khi jitter đang cố gắng để inline một phương pháp. Một quy tắc có vẻ như chỉ sử dụng cặp edx: eax register có thể được sử dụng cho mã nội tuyến có các biến cục bộ kiểu dài. Nhưng không phải edi: ebx. Không nghi ngờ gì 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 là các thanh ghi lưu trữ quan trọng.

Vì vậy, bạn nhận được phiên bản nhanh vì jitter biết trước rằng cơ thể phương thức chứa các câu lệnh try / catch. Nó biết nó không bao giờ có thể được inlined để 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ì người jitter không biết trước rằng nội tuyến sẽ không hoạt động. Nó chỉ phát hiện ra sau tạo mã cho thân phương thức.

Các lỗ hổng sau đó là nó đã không quay trở lại và tái tạo mã cho phương thức. Điều này là dễ hiểu, do những ràng buộc 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 vì nó có thêm 8 thanh ghi. Đối với một vì nó có thể lưu trữ một thời gian dài chỉ trong một đăng ký (như rax). Và sự chậm lại không xảy ra khi bạn sử dụng int thay vì dài vì jitter có tính linh hoạt hơn nhiều trong việc chọn thanh ghi.


65
2017-08-03 10:42





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


18
2018-01-20 13:15