【问题标题】:Why Is a Compiled Delegate Faster Than a Declared Delegate?为什么编译的委托比声明的委托快?
【发布时间】:2018-05-03 07:53:26
【问题描述】:

首先,这与Why is Func<> created from Expression> slower than Func<> declared directly? 不同,而且令人惊讶的是正好相反。此外,我在研究这个问题时发现的所有链接和问题都源自 2010-2012 年的时间段,所以我决定在这里打开一个新问题,看看是否有一些关于当前状态的讨论.NET 生态系统中的行为。

也就是说,我正在使用 .NET Core 2.0 和 .NET 4.7.1,并且看到一些关于从编译表达式创建的委托与被描述和声明为 CLR 对象的委托的一些奇怪的性能指标。

关于我如何偶然发现这个问题的一些背景信息,我正在做一个测试,涉及在 1,000 和 10,000 个对象的数组中选择数据,并注意到如果我使用编译表达式,它会得到更快的结果。我设法将其归结为一个非常简单的项目,该项目可以重现此问题,您可以在此处找到:

https://github.com/Mike-EEE/StackOverflow.Performance.Delegates

对于测试,我使用了两组基准测试,它们具有编译委托和声明委托配对,总共有四个核心基准测试。

第一个委托集由一个返回空字符串的空委托组成。第二组是一个委托,其中有一个简单的表达式。我想证明这个问题发生在最简单的委托以及其中包含已定义主体的委托中。

这些测试随后通过出色的 Benchmark.NET 性能产品在 CLR 运行时和 .NET Core 运行时上运行,总共产生了八个基准测试。此外,我还利用同样出色的 Benchmark.NET disassembly diagnoser 发出在基准测量的 JIT 期间遇到的反汇编。我在下面分享这个结果。

这是运行基准测试的代码。您可以看到它非常简单:

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

这些是我在 Benchmark.NET 中看到的结果:

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.16299.371 (1709/FallCreatorsUpdate/Redstone3)
Intel Core i7-4820K CPU 3.70GHz (Haswell), 1 CPU, 8 logical and 8 physical cores
.NET Core SDK=2.1.300-preview2-008533
  [Host] : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT
  Clr    : .NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0
  Core   : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT


             Method |  Job | Runtime |      Mean |     Error |    StdDev |
------------------- |----- |-------- |----------:|----------:|----------:|
      EmptyDeclared |  Clr |     Clr | 1.3691 ns | 0.0302 ns | 0.0282 ns |
      EmptyCompiled |  Clr |     Clr | 1.1851 ns | 0.0381 ns | 0.0357 ns |
 ExpressionDeclared |  Clr |     Clr | 1.3805 ns | 0.0314 ns | 0.0294 ns |
 ExpressionCompiled |  Clr |     Clr | 1.1431 ns | 0.0396 ns | 0.0371 ns |
      EmptyDeclared | Core |    Core | 1.5733 ns | 0.0329 ns | 0.0308 ns |
      EmptyCompiled | Core |    Core | 0.9326 ns | 0.0275 ns | 0.0244 ns |
 ExpressionDeclared | Core |    Core | 1.6040 ns | 0.0394 ns | 0.0368 ns |
 ExpressionCompiled | Core |    Core | 0.9380 ns | 0.0485 ns | 0.0631 ns |

请注意,使用已编译委托的基准测试始终更快。

最后,这里是每个基准测试遇到的反汇编结果:

<style type="text/css">
	table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
	td, th { padding: 6px 13px; border: 1px solid #ddd; }
	tr { background-color: #fff; border-top: 1px solid #ccc; }
	tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<table>
<thead>
<tr><th colspan="2">Delegates.EmptyDeclared</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8f0ea0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
		public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0ea4 4883c110        add     rcx,10h
00007ffd`4f8f0ea8 488b01          mov     rax,qword ptr [rcx]
00007ffd`4f8f0eab 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0eaf 33d2            xor     edx,edx
00007ffd`4f8f0eb1 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0eb4 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c8d8b0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
		public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d8b4 4883c110        add     rcx,10h
00007ffd`39c8d8b8 488b01          mov     rax,qword ptr [rcx]
00007ffd`39c8d8bb 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d8bf 33d2            xor     edx,edx
00007ffd`39c8d8c1 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d8c4 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.EmptyCompiled</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8e0ef0 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
		public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0ef4 4883c110        add     rcx,10h
00007ffd`4f8e0ef8 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`4f8e0efc 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f00 33d2            xor     edx,edx
00007ffd`4f8e0f02 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f05 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c8d900 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
		public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d904 4883c110        add     rcx,10h
00007ffd`39c8d908 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`39c8d90c 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d910 33d2            xor     edx,edx
00007ffd`39c8d912 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d915 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.ExpressionDeclared</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8e0f20 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
		public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0f24 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8e0f28 488b02          mov     rax,qword ptr [rdx]
00007ffd`4f8e0f2b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8e0f2f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f33 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f36 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c9d930 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
		public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d934 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d938 488b02          mov     rax,qword ptr [rdx]
00007ffd`39c9d93b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d93f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d943 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d946 90              nop

</code></pre></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr><th colspan="2">Delegates.ExpressionCompiled</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8f0f70 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
		public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0f74 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8f0f78 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`4f8f0f7c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8f0f80 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0f84 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0f87 90              nop

</code></pre></td>
<td style="vertical-align:top;"><pre><code>
00007ffd`39c9d980 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
		public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d984 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d988 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`39c9d98c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d990 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d994 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d997 90              nop

</code></pre></td>
</tr>
</tbody>
</table>

似乎声明和编译委托反汇编之间的唯一区别是声明的rcx 与在各自的第一个mov 操作中使用的编译的rcx+8。我在拆卸方面还没有说得那么好,因此将不胜感激获得有关此的上下文。乍一看,这似乎不会导致差异/改进,如果是这样,本地声明的委托也应该具有它(换句话说,一个错误)。

说了这么多,对我来说显而易见的问题是:

  1. 这是一个已知问题和/或错误吗?
  2. 我在这里做的事情完全不正常吗? (猜猜这应该是第一个问题。:))
  3. 那么指导是否总是尽可能地使用已编译的委托?正如我之前提到的,在编译的委托中发生的魔法似乎已经被嵌入到声明的委托中,所以这有点令人困惑。

为了完整起见,这里是示例中使用的所有代码的完整内容:

sealed class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<Delegates>();
    }
}

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

public struct DelegatePair<TFrom, TTo>
{
    DelegatePair(Func<TFrom, TTo> declared, Func<TFrom, TTo> compiled)
    {
        Declared = declared;
        Compiled = compiled;
    }

    public DelegatePair(Func<TFrom, TTo> declared, Expression<Func<TFrom, TTo>> expression) :
        this(declared, expression.Compile()) {}

    public Func<TFrom, TTo> Declared { get; }

    public Func<TFrom, TTo> Compiled { get; }
}

提前感谢您提供的任何帮助!

【问题讨论】:

  • 也许 expression.Compile() 返回的委托分配的内存位置比分配给 declared 的内存位置更方便,因此将委托加载到堆栈和调用所需的时间更短
  • 这是一个很好的理论,@BobDust。这在.NET中甚至可能吗?也就是说,是否可以将对象放置在首选位置?一个VIP堆,就像它一样? :) 我确实在LambdaExpression.Compile 方法中做了一些检查,我唯一能找到的是对Delegate.InternalAllocextern 方法调用,它返回一个MulticastDelegate。无法知道该值是如何在外部存储的,因为它是extern,所以你可能会在那里找到一些东西。但是,我从未听说过首选堆。欢迎提供与此相关的资源/链接。 :)
  • 在像这样的紧密循环基准测试情况下,我希望每个委托在进行基准测试时都驻留在 L1/指令缓存中。它在物理内存中的位置应该不会产生影响,因为它不太可能在基准运行时从缓存中清除。

标签: c# .net performance delegates linq-expressions


【解决方案1】:

我在这里做的事情完全不正常吗? (猜猜这应该是第一个问题。:))

我有理由确定您看到的反汇编仅适用于基准方法:加载委托及其参数所需的指令,然后调用委托。它确实包括每个代表的主体。

这就是为什么唯一的区别是 mov 指令之一中的相对偏移量:一个代表位于结构中的偏移量 0 处,另一个位于偏移量 8 处。交换 Compiled 的声明顺序和Declared,看看反汇编有什么变化。

我不知道有什么方法可以让 Benchmark.NET 为调用树中更深处的调用吐出反汇编。文档建议在[DisassemblyDiagnoser] 上将recursiveDepth 设置为某个值n &gt; 1 应该这样做,但在这种情况下似乎不起作用。


你是说我们没有看到额外的反汇编?

正确,您没有看到代表机构的反汇编。如果它们的编译方式有所不同,那将是可见的。

你是说我们没有看到额外的拆卸?由于两个身体完全一样(或者至少看起来是一样的),我进一步不清楚这是怎么回事。

身体不一定相同。对于基于Expression 的lambas,C# 编译器不会为描述的 表达式发出IL;相反,它发出一系列Expression 工厂调用以在运行时构造表达式树。该表达式树描述的代码应该功能等效到生成它的 C# 表达式,但它是由 LambdaCompiler 在运行时调用 Compile() 时编译的。 LINQ 表达式树与语言无关,不一定与 C# 编译器生成的表达式完全一致。因为 lambda 表达式是由不同的(并且不太复杂的)编译器编译的,所以生成的 IL 可能与 C# 编译器发出的有点不同。例如,lambda 编译器往往比 C# 编译器发出更多的临时局部变量,或者至少在我上次查看源代码时是这样。

确定每个委托的实际反汇编的最佳选择可能是在调试器中加载SOS.dll。我自己尝试这样做,但我似乎无法弄清楚如何让它在 VS2017 中工作。我过去从来没有遇到过麻烦。我还没有完全接受 VS2017 中的新项目模型,不知道如何启用非托管调试。


好的,我用 WinDbg 加载了 SOS.dll,经过一番谷歌搜索,我现在可以查看 IL 和反汇编。首先,让我们看一下 lambda 主体的方法描述符。这是声明的版本:

0:000> !DumpMD 000007fe97686148

Method Name:  StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Class:        000007fe977d14d0
MethodTable:  000007fe97686158
mdToken:      000000000600000e
Module:       000007fe976840c0
IsJitted:     yes
CodeAddr:     000007fe977912b0
Transparency: Critical

这是编译版本:

0:000> !DumpMD 000007fe97689390

Method Name:  DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Class:        000007fe97689270
MethodTable:  000007fe976892e8
mdToken:      0000000006000000
Module:       000007fe97688af8
IsJitted:     yes
CodeAddr:     000007fe977e0150
Transparency: Transparent

我们可以转储IL,看看其实是一样的:

0:000> !DumpIL 000007fe97686148

IL_0000: ldarg.1 
IL_0001: callvirt 6000002 System.String.get_Length()
IL_0006: ret 

0:000> !DumpIL 000007fe97689390

IL_0000: ldarg.1 
IL_0001: callvirt System.String::get_Length 
IL_0006: ret

反汇编也是如此:

0:000> !U 000007fe977912b0

Normal JIT generated code
StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Begin 000007fe977912b0, size 4
W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\Delegates.cs @ 14:

000007fe`977912b0 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977912b3 c3              ret

0:000> !U 000007fe977e0150

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

所以,我们有相同的 IL 和相同的程序集。 差异从何而来?让我们看看实际的委托实例。我指的不是 lambda 主体,而是我们用来调用 lambda 的 Delegate 对象。

0:000> !DumpVC /d 000007fe97686040 0000000002a84410

Name:        StackOverflow.Performance.Delegates.DelegatePair`2[[System.String, mscorlib],[System.Int32, mscorlib]]
MethodTable: 000007fe97686040
EEClass:     000007fe977d12d0
Size:        32(0x20) bytes
File:        W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\bin\Release\net461\StackOverflow.Performance.Delegates.exe
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fef692e400  4000001        0 ...Int32, mscorlib]]  0 instance 0000000002a8b4d8 <Declared>k__BackingField
000007fef692e400  4000002        8 ...Int32, mscorlib]]  0 instance 0000000002a8d3f8 <Compiled>k__BackingField

我们有两个委托值:在我的例子中,Declared 位于 02a8b4d8,而 Compiled 位于 02a8d3f8(这些地址对于我的流程来说是唯一的)。如果我们使用!DumpObject 转储这些地址中的每一个并查找_methodPtr 值,我们可以看到已编译方法的地址。然后我们可以使用!U 转储程序集:

0:000> !U 7fe977e0150 

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

好的,对于Compiled,我们可以看到我们直接调用了 lambda 主体。好的。但是当我们转储 Declared 版本的反汇编时,我们会看到一些不同的东西:

0:000> !U 7fe977901d8 

Unmanaged code

000007fe`977901d8 e8f326635f      call    clr!PrecodeFixupThunk (000007fe`f6dc28d0)
000007fe`977901dd 5e              pop     rsi
000007fe`977901de 0400            add     al,0
000007fe`977901e0 286168          sub     byte ptr [rcx+68h],ah
000007fe`977901e3 97              xchg    eax,edi
000007fe`977901e4 fe07            inc     byte ptr [rdi]
000007fe`977901e6 0000            add     byte ptr [rax],al
000007fe`977901e8 0000            add     byte ptr [rax],al
000007fe`977901ea 0000            add     byte ptr [rax],al
000007fe`977901ec 0000            add     byte ptr [rax],al

你好。我记得在blog post by Matt Warren 中看到了对clr!PrecodeFixupThunk 的引用。我的理解是,normal IL 方法的入口点(与像我们基于 LINQ 的方法这样的 dynamic 方法相反)调用了一个修复方法,该方法在第一次调用,然后在后续调用中调用 JITed 方法。调用“声明的”委托时该“重击”的额外开销似乎是原因。 'compiled' 委托没有这样的 thunk;委托直接指向已编译的 lambda 主体。

【讨论】:

  • 好的,非常感谢您在这里提供答案@Mike(嘿,好名字!)以及在主要问题中提供额外的上下文)。虽然这确实说明了反汇编,但我仍然不清楚为什么两种类型的代表之间的性能指标不同。你是说我们没有看到额外的拆卸吗?由于两个机构完全相同(或至少,似乎是相同的),我进一步不清楚这是怎么回事。
  • 嗨,迈克,我尝试在回答中解决您的后续问题。
  • 太棒了!这是非常有用和有见地的。感谢您添加上下文。它现在得到我的支持。不要挑剔,但在将其标记为答案之前,我想 100% 确定您的理论/怀疑。希望你没问题。 :) 我会调查SOS,看看我自己能不能让它工作。否则,如果您可以确定地验证(输出显示差异),那将是我的偏好。我主要关心的是你说它是一个不太复杂的编译器,但它比 .NET Core 2.0 编译器更快。
  • 我的荣幸。我学到了一些有趣的东西。我希望我知道 为什么 声明的版本有 thunk 而动态版本没有。如果我发现了,我会更新我的答案。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-04-09
  • 2011-10-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多