您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > Go语言

如何在 GO 中写出准确的基准测试

时间:2023-11-24 15:07:23  来源:微信公众号  作者:技术的游戏

如何在 GO 中写出准确的基准测试
一般来说,我们不应该对性能进行猜测。在编写优化时,会有许多因素可能起作用,即使我们对结果有很强的看法,测试它们很少是一个坏主意。然而,编写基准测试并不简单。很容易编写不准确的基准测试,并且基于这些测试得出错误的假设。这篇文章的目标是探讨导致不准确的四个常见和具体陷阱:

  • • 不重置或暂停计时器

  • • 对微基准测试做出错误假设

  • • 不注意编译器优化

  • • 被观察效应所误导

通用概念

在讨论这些陷阱之前,让我们简要回顾一下 Go 语言中基准测试的工作原理。基准测试的框架大致如下:

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo()
    }
}

函数名以Benchmark前缀开头。被测试的函数(foo)在for循环内被调用。b.N代表着可变数量的迭代次数。在运行基准测试时,Go 会尝试使其匹配所请求的基准测试时间。基准测试时间默认设置为1秒,可以使用-benchtime标志进行更改。b.N从1开始;如果基准测试在1秒内完成,b.N会增加,然后再次运行基准测试,直到b.N大致匹配benchtime为止。

$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4                73          16511228 ns/op

在这里,基准测试大约花费了1秒钟,foo被执行了73次,平均执行时间为16,511,228纳秒。我们可以使用-benchtime来更改基准测试时间:

$ go test -bench=. -benchtime=2s
BenchmarkFoo-4               150          15832169 ns/op

foo 的执行次数大约是上一个基准测试的两倍。

接下来,让我们看一些常见的陷阱。

不重置或暂停计时器

在某些情况下,我们需要在基准测试循环之前执行一些操作。这些操作可能需要相当长的时间(例如,生成一个大型数据切片),可能会对基准测试结果产生显著影响:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    }
}

在这种情况下,我们可以在进入循环之前使用ResetTimer方法:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    b.ResetTimer() // Reset the benchmark timer
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    }
}

调用ResetTimer会将自测试开始以来的经过时间和内存分配计数器归零。这样,一个昂贵的设置步骤可以从测试结果中排除。

如果我们不仅需要在测试中执行一次昂贵的设置步骤,而是需要在每个循环迭代中都执行呢?

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        expensiveSetup()
        functionUnderTest()
    }
}

我们不能重置计时器,因为那样会在每次循环迭代期间执行。但是我们可以停止和恢复基准测试计时器,将调用expensiveSetup包裹起来:

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        b.StopTimer() // Pause the benchmark timer
        expensiveSetup()
        b.StartTimer() // Resume the benchmark timer
        functionUnderTest()
    }
}

在这里,我们暂停基准测试计时器来执行昂贵的设置步骤,然后恢复计时器。

注意: 关于这种方法有一个需要记住的地方:如果被测试的函数执行速度远远比设置函数要快,那么基准测试可能会花费太长时间才能完成。原因是它需要比 benchtime 更长的时间才能完成。基准测试时间的计算完全基于 functionUnderTest 的执行时间。所以,如果在每个循环迭代中等待了相当长的时间,基准测试就会比1秒要慢得多。如果我们想保持基准测试,一种可能的缓解方法是减少 benchtime 。

我们必须确保使用计时器方法来保持基准测试的准确性。

对微基准测试做出错误假设

微基准测试测量的是微小的计算单元,很容易对它做出错误的假设。比如说,我们不确定是应该使用atomic.StoreInt32还是atomic.StoreInt64(假设处理的值始终适合32位)。我们想编写一个基准测试来比较这两个函数:

func BenchmarkAtomicStoreInt32(b *testing.B) {
    var v int32
    for i := 0; i < b.N; i++ {
        atomic.StoreInt32(&v, 1)
    }
}

func BenchmarkAtomicStoreInt64(b *testing.B) {
    var v int64
    for i := 0; i < b.N; i++ {
        atomic.StoreInt64(&v, 1)
    }
}

如果我们运行这个基准测试,以下是一些示例输出:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4    197107742           5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4    213917528           5.134 ns/op

我们很容易认为这个基准测试并且决定使用atomic.StoreInt64,因为它看起来更快。现在,为了进行一次公平的基准测试,我们改变顺序,先测试atomic.StoreInt64,然后测试atomic.StoreInt32。以下是一些示例输出:

BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4    224900722           5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4    230253900           5.159 ns/op

这次,atomic.StoreInt32有更好的结果。发生了什么事?

在微基准测试中,许多因素都会影响结果,比如在运行基准测试时的机器活动、电源管理、热管理和一系列指令的更好的缓存对齐。我们必须记住,许多因素,甚至超出我们 Go 项目范围的因素,都可能会影响结果。

注意: 我们应该确保运行基准测试的机器处于空闲状态。但是,外部进程可能在后台运行,这可能会影响基准测试结果。因此,像 perflock 这样的工具可以限制基准测试可以消耗的CPU。例如,我们可以使用总可用CPU的70%来运行基准测试,将30%分配给操作系统和其他进程,减少机器活动因素对结果的影响。

一个选择是使用-benchtime选项增加基准测试时间。类似于概率论中的大数定律,如果我们运行大量次数的基准测试,它应该趋向于接近其期望值(假设我们忽略了指令缓存等机制的好处)。

另一个选择是在经典基准测试工具之上使用外部工具。例如,benchstat工具是golang.org/x代码库的一部分,它允许我们计算和比较有关基准测试执行的统计数据。

让我们使用-count选项运行基准测试10次,并将输出重定向到一个特定的文件:

$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4     234935682                5.124 ns/op
BenchmarkAtomicStoreInt32-4     235307204                5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4     235548591                5.107 ns/op
BenchmarkAtomicStoreInt64-4     235210292                5.090 ns/op
// ...

然后我们可以在这个文件上运行benchstat

$ benchstat stats.txt
name                time/op
AtomicStoreInt32-4  5.10ns ± 1%
AtomicStoreInt64-4  5.10ns ± 1%

结果是一样的:两个函数的平均完成时间都是5.10纳秒。我们还可以看到给定基准测试执行间的百分比变化:±1%。这个指标告诉我们,两个基准测试都是稳定的,这让我们对计算出的平均结果更有信心。因此,我们不能得出atomic.StoreInt32atomic.StoreInt64快或慢的结论,而是可以得出对于我们测试的用途(在特定的Go版本和特定的机器上),它们的执行时间是相似的。

总的来说,我们在进行微基准测试时应该保持谨慎。许多因素都可以对结果产生重大影响,并潜在地导致错误的假设。增加基准测试时间或重复基准测试的执行,并使用诸如benchstat之类的工具计算统计数据,可以是限制外部因素并获得更准确结果的有效方法,从而得出更好的结论。

我们还应该注意,如果另一个系统最终运行该应用程序,要小心使用在特定机器上执行的微基准测试的结果。生产系统的行为可能与我们运行微基准测试的系统大不相同。

不注意编译器优化

与编写基准测试相关的另一个常见错误是被编译器优化所欺骗,这也可能导致错误的基准测试假设。在这一节中,我们将看看Go语言的14813号问题(https://Github.com/golang/go/issues/14813,也被Go项目成员Dave Cheney讨论过),涉及到一个人口统计函数(一个计算设置为1的位数的函数):

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
    x -= (x >> 1) & m1
    x = (x & m2) + ((x >> 2) & m2)
    x = (x + (x >> 4)) & m4
    return (x * h01) >> 56
}

这个函数接受一个uint64并返回一个uint64。为了对这个函数进行基准测试,我们可以编写以下内容:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        popcnt(uint64(i))
    }
}

然而,如果我们执行这个基准测试,结果会非常低得令人惊讶:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op

一个持续时间为0.28纳秒大约是一个时钟周期,所以这个数字是非常不合理地低。问题在于开发者对编译器优化不够谨慎。在这种情况下,被测试的函数非常简单,可以成为内联的候选对象:一种优化,用被调用函数的主体替换函数调用,让我们避免函数调用,这具有很小的开销。一旦函数被内联,编译器注意到调用没有副作用,并用以下基准测试替换了它:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // Empty
    }
}

现在基准测试是空的,这就是我们得到接近一个时钟周期结果的原因。为了防止这种情况发生,最佳实践是遵循以下模式:

  1. 1. 在每个循环迭代中,将结果赋值给一个局部变量(在基准测试函数的上下文中为局部变量)。

  2. 2. 将最新的结果赋值给一个全局变量。

在我们的情况下,我们编写以下基准测试:

var global uint64 // Define a global variable

func BenchmarkPopcnt2(b *testing.B) {
    var v uint64 // Define a local variable
    for i := 0; i < b.N; i++ {
        v = popcnt(uint64(i)) // Assign the result to the local variable
    }
    global = v // Assign the result to the global variable
}

global 是一个全局变量,而 v 是一个局部变量,其作用域限定在基准测试函数内部。在每个循环迭代中,我们将 popcnt 的结果赋值给局部变量 v。然后将最新的结果赋值给全局变量 global

如果我们运行这两个基准测试,现在结果之间会有显著的差异:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
BenchmarkPopcnt2-4      606402058                1.993 ns/op

BenchmarkPopcnt2 是基准测试的准确版本。它确保我们避免了内联优化,这种优化可能会人为降低执行时间,甚至去除对被测试函数的调用。依赖BenchmarkPopcnt1的结果可能会导致错误的假设。

让我们记住避免编译器优化误导基准测试结果的模式:将被测试函数的结果赋值给一个局部变量,然后将最新的结果赋值给一个全局变量。这种最佳实践还可以防止我们做出不正确的假设。

被观察效应的欺骗

在物理学中,观察者效应是通过观察行为干扰观察系统的现象。这种效应也可以在基准测试中看到,并可能导致对结果做出错误的假设。让我们看一个具体的例子,然后尝试减轻这种影响。

我们想要实现一个接收int64元素矩阵的函数。这个矩阵有固定的512列,我们想要计算前八列的总和,如图11.2所示。

如何在 GO 中写出准确的基准测试
img

计算前八列的总和

为了优化的目的,我们还想确定变化列数是否会产生影响,所以我们也实现了一个有513列的第二个函数。实现如下:

func calculateSum512(s [][512]int64) int64 {
    var sum int64
    for i := 0; i < len(s); i++ { // Iterate over each row
        for j := 0; j < 8; j++ { // Iterate over the first eight columns
            sum += s[i][j] // Increment sum
        }
    }
    return sum
}

func calculateSum513(s [][513]int64) int64 {
    // Same implementation as calculateSum512
}

我们遍历每一行,然后遍历前八列,并增加一个我们要返回的总和变量。在calculateSum513中的实现保持不变。

我们想对这些函数进行基准测试,以决定在固定行数的情况下哪一个性能最好:

const rows = 1000

var res int64

func BenchmarkCalculateSum512(b *testing.B) {
    var sum int64
    s := createMatrix512(rows) // Create a matrix of 512 columns
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sum = calculateSum512(s) // Create a matrix of 512 columns
    }
    res = sum
}

func BenchmarkCalculateSum513(b *testing.B) {
    var sum int64
    s := createMatrix513(rows) // Create a matrix of 513 columns
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sum = calculateSum513(s) // Calculate the sum
    }
    res = sum
}

我们希望只创建一次矩阵,以限制对结果的影响。因此,我们在循环外调用createMatrix512createMatrix513。我们可能期望结果会类似,因为我们只想遍历前八列,但事实并非如此(在我的机器上):

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4        81854             15073 ns/op
BenchmarkCalculateSum513-4       161479              7358 ns/op

第二个拥有513列的基准测试要快大约50%。再次强调,因为我们只遍历了前八列,这个结果相当令人惊讶。

要理解这种差异,我们需要了解CPU缓存的基础知识。简而言之,CPU由不同的缓存组成(通常是L1、L2和L3)。这些缓存降低了从主存储器访问数据的平均成本。在某些条件下,CPU可以从主存储器获取数据并将其复制到L1。在这种情况下,CPU试图将calculateSum感兴趣的矩阵子集(每行的前八列)放入L1。然而,一个情况下矩阵可以完全放入内存(513列),而另一个情况下不能(512列)。

回到基准测试,主要问题在于我们在两种情况下都重复使用相同的矩阵。因为该函数重复执行数千次,我们并未测量接收纯新矩阵的函数执行情况。相反,我们测量的是一个已经包含在缓存中的矩阵的子集的函数。因此,由于calculateSum513导致的缓存未命中更少,它具有更好的执行时间。

这是观察效应的一个例子。因为我们一直在观察重复调用的CPU密集型函数,CPU缓存可能会起作用,并且会对结果产生显著影响。在这个例子中,为了防止这种效应,我们应该在每次测试中创建一个新的矩阵,而不是重复使用一个:

func BenchmarkCalculateSum512(b *testing.B) {
    var sum int64
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        s := createMatrix512(rows) // Create a new matrix during each loop iteration
        b.StartTimer()
        sum = calculateSum512(s)
    }
    res = sum
}

现在在每次循环迭代中都创建了一个新的矩阵。如果我们再次运行基准测试(并调整benchtime——否则执行时间太长),结果会更接近:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4         1116             33547 ns/op
BenchmarkCalculateSum513-4          998             35507 ns/op

不是做出calculateSum513更快的错误假设,我们看到当接收新矩阵时,两个基准测试的结果更接近。

正如我们在本文中看到的那样,因为我们重复使用了同一个矩阵,CPU缓存对结果产生了显著影响。为了防止这种情况发生,我们必须在每个循环迭代期间创建一个新的矩阵。总的来说,我们应该记住,在观察一个被测试函数的情况下,可能会在结果中产生显著差异,特别是在涉及低级优化的CPU密集型函数的微基准测试中。强制基准测试在每次迭代期间重新创建数据可能是防止这种影响的一种好方法。

结论

  • • 使用时间方法来保持基准测试的准确性。

  • • 当处理微基准测试时,增加benchtime或使用benchstat等工具可能会有所帮助。

  • • 注意微基准测试的结果,如果最终运行应用程序的系统与运行微基准测试的系统不同。

  • • 确保被测试函数产生了副作用,以防止编译器优化让您对基准测试结果产生误解。

  • • 为了防止观察者效应,强制基准测试重新创建CPU密集型函数使用的数据。



Tags:GO   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
机械设备B2B工厂外贸网站如何做Google谷歌SEO优化关键字排名?
在今天的全球化市场中,机械设备行业正面临着激烈的竞争。要在这一领域脱颖而出,拥有一个优化良好的B2B外贸网站至关重要。通过精准的谷歌SEO关键字排名,您的网站可以吸引更多海...【详细内容】
2024-04-08  Search: GO  点击:(4)  评论:(0)  加入收藏
Google搜索引擎索引的网页数量有多少?谷歌官方提供数据进行参考
Google搜索引擎索引的网页数量有多少?二十世纪九十年代,网页的索引数量成了一个各大搜索引擎相互对比的指标。小编记得2000年谷歌搜索引擎的首页搜索框上方,还标记着谷歌索引的...【详细内容】
2024-03-27  Search: GO  点击:(12)  评论:(0)  加入收藏
在Java应用程序中释放峰值性能:配置文件引导优化(PGO)概述
译者 | 李睿审校 | 重楼在Java开发领域,优化应用程序的性能是开发人员的持续追求。配置文件引导优化(Profile-Guided Optimization,PGO)是一种功能强大的技术,能够显著地提高Ja...【详细内容】
2024-03-18  Search: GO  点击:(24)  评论:(0)  加入收藏
宝藏级Go语言开源项目——教你自己动手开发互联网搜索引擎
DIYSearchEngine 是一个能够高速采集海量互联网数据的开源搜索引擎,采用 Go 语言开发。Github 地址:https://github.com/johnlui/DIYSearchEngine运行方法首先,给自己准备一杯...【详细内容】
2024-03-12  Search: GO  点击:(18)  评论:(0)  加入收藏
Go Gin框架实现优雅地重启和停止
在Web应用程序中,有时候我们需要重启或停止服务器,无论是因为更新代码还是进行例行维护。在这种情景下,我们需要保证应用程序的可用性和数据的一致性。这就需要优雅地关闭和重...【详细内容】
2024-01-30  Search: GO  点击:(67)  评论:(0)  加入收藏
如何让Go程序以后台进程或daemon方式运行
本文探讨了如何通过Go代码实现在后台运行的程序。最近我用Go语言开发了一个WebSocket服务,我希望它能在后台运行,并在异常退出时自动重新启动。我的整体思路是将程序转为后台...【详细内容】
2024-01-26  Search: GO  点击:(60)  评论:(0)  加入收藏
深入Go底层原理,重写Redis中间件实战
Go语言以其简洁、高效和并发性能而闻名,深入了解其底层原理可以帮助我们更好地利用其优势。在本文中,我们将探讨如何深入Go底层原理,以及如何利用这些知识重新实现一个简单的Re...【详细内容】
2024-01-25  Search: GO  点击:(66)  评论:(0)  加入收藏
支付宝宣布更换Logo
鞭牛士 1月19日消息,今日,支付宝宣布更新Logo,此次最大的变化在于去掉了外框与文字,仅保留最具辨识度的“支”字标识。据了解,这是支付宝时隔4年再次更换Logo。支付宝App目前已用...【详细内容】
2024-01-19  Search: GO  点击:(71)  评论:(0)  加入收藏
Go 内存优化与垃圾收集
Go提供了自动化的内存管理机制,但在某些情况下需要更精细的微调从而避免发生OOM错误。本文将讨论Go的垃圾收集器、应用程序内存优化以及如何防止OOM(Out-Of-Memory)错误。Go...【详细内容】
2024-01-15  Search: GO  点击:(61)  评论:(0)  加入收藏
Go函数指针是如何让你的程序变慢的?
导读Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优...【详细内容】
2024-01-15  Search: GO  点击:(85)  评论:(0)  加入收藏
▌简易百科推荐
宝藏级Go语言开源项目——教你自己动手开发互联网搜索引擎
DIYSearchEngine 是一个能够高速采集海量互联网数据的开源搜索引擎,采用 Go 语言开发。Github 地址:https://github.com/johnlui/DIYSearchEngine运行方法首先,给自己准备一杯...【详细内容】
2024-03-12  OSC开源社区    Tags:Go语言   点击:(18)  评论:(0)  加入收藏
Go Gin框架实现优雅地重启和停止
在Web应用程序中,有时候我们需要重启或停止服务器,无论是因为更新代码还是进行例行维护。在这种情景下,我们需要保证应用程序的可用性和数据的一致性。这就需要优雅地关闭和重...【详细内容】
2024-01-30  源自开发者  微信公众号  Tags:Go   点击:(67)  评论:(0)  加入收藏
如何让Go程序以后台进程或daemon方式运行
本文探讨了如何通过Go代码实现在后台运行的程序。最近我用Go语言开发了一个WebSocket服务,我希望它能在后台运行,并在异常退出时自动重新启动。我的整体思路是将程序转为后台...【详细内容】
2024-01-26  Go语言圈  微信公众号  Tags:Go程序   点击:(60)  评论:(0)  加入收藏
深入Go底层原理,重写Redis中间件实战
Go语言以其简洁、高效和并发性能而闻名,深入了解其底层原理可以帮助我们更好地利用其优势。在本文中,我们将探讨如何深入Go底层原理,以及如何利用这些知识重新实现一个简单的Re...【详细内容】
2024-01-25  547蓝色星球    Tags:Go   点击:(66)  评论:(0)  加入收藏
Go 内存优化与垃圾收集
Go提供了自动化的内存管理机制,但在某些情况下需要更精细的微调从而避免发生OOM错误。本文将讨论Go的垃圾收集器、应用程序内存优化以及如何防止OOM(Out-Of-Memory)错误。Go...【详细内容】
2024-01-15  DeepNoMind  微信公众号  Tags:Go   点击:(61)  评论:(0)  加入收藏
Go函数指针是如何让你的程序变慢的?
导读Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优...【详细内容】
2024-01-15  腾讯云开发者  微信公众号  Tags:Go函数   点击:(85)  评论:(0)  加入收藏
Go编程中调用外部命令的几种场景
在很多场合, 使用Go语言需要调用外部命令来完成一些特定的任务, 例如: 使用Go语言调用Linux命令来获取执行的结果,又或者调用第三方程序执行来完成额外的任务。在go的标准库...【详细内容】
2024-01-09  suntiger    Tags:Go编程   点击:(100)  评论:(0)  加入收藏
Go 语言不支持并发读写 Map,为什么?
Go语言的map类型不支持并发读写的主要原因是并发读写会导致数据竞态(data race),这意味着多个 goroutine 可能同时访问并修改同一个 map,从而引发不确定的结果。在Go语言的设计...【详细内容】
2024-01-05  Go语言圈  微信公众号  Tags:Go 语言   点击:(77)  评论:(0)  加入收藏
Go微服务入门到容器化实践
Go微服务入门到容器化实践Go 是一门高效、现代化、快速增长的编程语言,非常适合构建 Web 应用程序。而 Docker 是一种轻量级的容器化技术,能够使得您的应用程序在任何地方运行...【详细内容】
2024-01-01  大雷家吃饭    Tags:Go微服务   点击:(61)  评论:(0)  加入收藏
你是否想知道如何应对高并发?Go语言为你提供了答案!
并发编程是当前软件领域中不可忽视的一个关键概念。随着CPU等硬件的不断发展,我们都渴望让我们的程序运行速度更快、更快。而Go语言在语言层面天生支持并发,充分利用现代CPU的...【详细内容】
2023-12-29  灵墨AI探索室  微信公众号  Tags:Go语言   点击:(107)  评论:(0)  加入收藏
站内最新
站内热门
站内头条