【问题标题】:How can I improve the speed and memory usage of calculating the size of the N largest files?如何提高计算 N 个最大文件大小的速度和内存使用率?
【发布时间】:2021-12-06 08:57:06
【问题描述】:

我正在获取文件夹中 32 个最大文件的总字节数:

$big32 = Get-ChildItem c:\\temp -recurse |
    Sort-Object length -descending |
    select-object -first 32 |
    measure-object -property length –sum

$big32.sum /1gb

但是,它的工作速度非常缓慢。我们在 140 万个文件中拥有大约 10 TB 的数据。

【问题讨论】:

  • 10TB 的数据,但是有多少个文件?
  • -File 传递给Get-ChildItem 以仅枚举文件。根据c:\temp\ 的组成(文件数与目录数),这可能会提高性能。另外,“非常慢”有多慢?
  • @LasseV.Karlsen 140 万个文件
  • 嗯,是的,按长度对 140 万个文件进行排序需要一段时间。即使在最低级别也是如此(也就是说,如果您直接使用 Windows API)。 [IO.Directory]::EnumerateFiles 可用于消防软管枚举,它将以任意顺序给出文件,但仍然需要进行排序。也就是说,在这里使用EnumerateFiles 作为基础可能仍然比Get-ChildItem 快得多。

标签: powershell performance get-childitem memory-efficient directory-listing


【解决方案1】:

以下仅使用 PowerShell cmdlet 实现了改进。按照this answer 的建议,使用System.IO.Directory.EnumerateFiles() 作为基础可能会带来另一个性能提升,但您应该自己进行测量以进行比较。

(Get-ChildItem c:\temp -Recurse -File).ForEach('Length') | 
    Sort-Object -Descending -Top 32 | 
    Measure-Object -Sum

这应该会大大减少内存消耗,因为它只对数字数组而不是FileInfo 对象数组进行排序。也许由于更好的缓存,它也更快一些(一个数字数组存储在一个连续的、缓存友好的内存块中,而一个对象数组只以连续的方式存储引用,但对象本身可以分散在各处在内存中)。

注意使用.ForEach('Length') 而不是仅仅使用.Length,因为member enumeration ambiguity

通过使用Sort-Object 参数-Top,我们可以摆脱Select-Object cmdlet,进一步减少管道开销。

【讨论】:

  • 谢谢,第一次听说成员枚举。缺点是您丢失了文件名,但这不是 OP 的要求。我将修改我的答案以仅返回长度,以便 OP 可以对速度/内存进行适当的比较。
  • 刚刚尝试了 EnumerateFiles 和成员枚举的组合。对于我所做的测试,这是最快的一致。随时将其添加到您的答案中,我将删除我的。 ([System.IO.Directory]::EnumerateFiles('c:\temp', '*.*', [System.IO.SearchOption]::AllDirectories)).ForEach({[System.IO.FileInfo]::New($_).Length}) | Sort-Object -Descending -Top 32 | Measure-Object -Sum
  • @LievenKeersmaekers 对于可读性比性能更重要的情况,我想将我的答案保留为“纯 PowerShell 方式”。您提出了EnumerateFiles,因此对它的任何进一步改进也属于您的答案。干杯!
  • @Arbelac 抱歉,我对运行空间没有经验。
【解决方案2】:

我能想到一些改进,尤其是内存使用,但是跟随应该比Get-ChildItem快很多

[System.IO.Directory]::EnumerateFiles('c:\temp', '*.*', [System.IO.SearchOption]::AllDirectories) | 
    Foreach-Object {
        [PSCustomObject]@{
            filename = $_
            length = [System.IO.FileInfo]::New($_).Length
        }
    } | 
    Sort-Object length -Descending | 
    Select-Object -First 32

编辑

我会考虑尝试实现implit heap 以减少内存使用量而不影响性能(甚至可能改进它...待测试)

编辑 2

如果不需要文件名,最简单的内存收益就是不将它们包含在结果中。

[System.IO.Directory]::EnumerateFiles('c:\temp', '*.*', [System.IO.SearchOption]::AllDirectories) | 
    Foreach-Object {
        [System.IO.FileInfo]::New($_).Length
    } | 
    Sort-Object length -Descending | 
    Select-Object -First 32

【讨论】:

    【解决方案3】:

    首先,如果您要使用Get-ChildItem,那么您应该传递-File 开关参数,以便[System.IO.DirectoryInfo] 实例永远不会进入管道。

    其次,您没有将-Force 开关参数传递给Get-ChildItem,因此不会检索到该目录结构中的任何隐藏文件。

    第三,请注意,您的代码正在检索 32 个最大的文件,而不是具有 32 个最大长度的文件。也就是说,如果文件 31、32 和 33 的长度都相同,则文件 33 将被任意排除在最终计数之外。如果这种区别对您很重要,您可以像这样重写您的代码...

    $filesByLength = Get-ChildItem -File -Force -Recurse -Path 'C:\Temp\' |
        Group-Object -AsHashTable -Property Length
    $big32 = $filesByLength.Keys |
        Sort-Object -Descending |
        Select-Object -First 32 |
        ForEach-Object -Process { $filesByLength[$_] } |
        Measure-Object -Property Length -Sum
    

    $filesByLength 是一个[Hashtable],它从一个长度映射到具有该长度的文件。 Keys 属性包含所有检索到的文件的所有唯一长度,因此我们获得了 32 个最大的键/长度,并使用每一个将具有该长度的所有文件发送到管道中。

    最重要的是,对检索到的文件进行排序以找到最大的文件是有问题的,原因如下:

    • 在所有输入数据都可用之前,无法开始排序,这意味着此时所有 140 万个 [System.IO.FileInfo] 实例都将存在于内存中。
      • 我不确定Sort-Object 是如何缓冲传入管道数据的,但我想它会是某种列表,每次需要更多容量时,它的大小都会翻倍,从而导致内存中的更多垃圾需要清理。
    • 140 万个[System.IO.FileInfo] 实例中的每一个都将被第二次访问以获取它们的Length 属性,同时也发生任何排序操作(取决于Sort-Object 使用的算法)。

    既然我们只关心 140 万个文件中最大的 32 个文件/长度,那么如果我们只跟踪这 32 个而不是全部 140 万个会怎样?考虑一下我们是否只想找到单个最大的文件...

    $largestFileLength = 0
    $largestFile = $null
    
    foreach ($file in Get-ChildItem -File -Force -Recurse -Path 'C:\Temp\')
    {
        # Track the largest length in a separate variable to avoid two comparisons...
        #     if ($largestFile -eq $null -or $file.Length -gt $largestFile.Length)
        if ($file.Length -gt $largestFileLength)
        {
            $largestFileLength = $file.Length
            $largestFile = $file
        }
    }
    
    Write-Host -Message "The largest file is named ""$($largestFile.Name)"" and has length $largestFileLength."
    

    Get-ChildItem ... | Sort-Object -Property Length -Descending | Select-Object -First 1 相比,这具有以下优点:一次只有一个[FileInfo] 对象处于“运行中”,并且整个[System.IO.FileInfo]s 集仅被枚举一次。现在我们需要做的就是采用相同的方法,但从 1 个文件/长度的“槽”扩展到 32...

    $basePath = 'C:\Temp\'
    $lengthsToKeep = 32
    $includeZeroLengthFiles = $false
    
    $listType = 'System.Collections.Generic.List[System.IO.FileInfo]'
    # A SortedDictionary[,] could be used instead to avoid having to fully enumerate the Keys
    # property to find the new minimum length, but add/remove/retrieve performance is worse
    $dictionaryType = "System.Collections.Generic.Dictionary[System.Int64, $listType]"
    
    # Create a dictionary pre-sized to the maximum number of lengths to keep
    $filesByLength = New-Object -TypeName $dictionaryType -ArgumentList $lengthsToKeep
    
    # Cache the minimum length currently being kept
    $minimumKeptLength = -1L
    
    Get-ChildItem -File -Force -Recurse -Path $basePath |
        ForEach-Object -Process {
            if ($_.Length -gt 0 -or $includeZeroLengthFiles)
            {
                $list = $null
                if ($filesByLength.TryGetValue($_.Length, [ref] $list))
                {
                    # The current file's length is already being kept
                    # Add the current file to the existing list for this length
                    $list.Add($_)
                }
                else
                {
                    # The current file's length is not being kept
    
                    if ($filesByLength.Count -lt $lengthsToKeep)
                    {
                        # There are still available slots to keep more lengths
    
                        $list = New-Object -TypeName $listType
    
                        # The current file's length will occupy an empty slot of kept lengths
                    }
                    elseif ($_.Length -gt $minimumKeptLength)
                    {
                        # There are no available slots to keep more lengths
                        # The current file's length is large enough to keep
    
                        # Get the list for the minimum length
                        $list = $filesByLength[$minimumKeptLength]
    
                        # Remove the minimum length to make room for the current length
                        $filesByLength.Remove($minimumKeptLength) |
                            Out-Null
    
                        # Reuse the list for the now-removed minimum length instead of allocating a new one
                        $list.Clear()
    
                        # The current file's length will occupy the newly-vacated slot of kept lengths
                    }
                    else
                    {
                        # There are no available slots to keep more lengths
                        # The current file's length is too small to keep
                        return
                    }
                    $list.Add($_)
    
                    $filesByLength.Add($_.Length, $list)
                    $minimumKeptLength = ($filesByLength.Keys | Measure-Object -Minimum).Minimum
                }
            }
        }
    
    # Unwrap the files in each by-length list
    foreach ($list in $filesByLength.Values)
    {
        foreach ($file in $list)
        {
            $file
        }
    }
    

    我采用了上述方法,即检索具有 32 个最大长度的文件。 [Dictionary[Int64, List[FileInfo]]] 用于跟踪这 32 个最大长度以及具有该长度的相应文件。对于每个输入文件,我们首先检查它的长度是否是迄今为止最大的,如果是,则将该文件添加到现有的List[FileInfo] 中以获得该长度。否则,如果字典中还有空间,我们可以无条件添加输入文件及其长度,或者如果输入文件至少大于最小跟踪长度,我们可以删除该最小长度并在其位置添加输入文件及其长度。一旦没有更多的输入文件,我们就会从[Dictionary[Int64, [List[FileInfo]]]] 中的所有[List[FileInfo]]s 中输出所有[FileInfo] 对象。

    我运行了这个简单的基准测试模板...

    1..5 |
        ForEach-Object -Process {
            [GC]::Collect()
    
            return Measure-Command -Expression {
                # Code to test
            }
        } | Measure-Object -Property 'TotalSeconds' -Minimum -Maximum -Average
    

    ...在 PowerShell 7.2 上针对我的 $Env:WinDir 目录(325,000 个文件),结果如下:

    # Code to test Minimum Maximum Average Memory usage*
    Get-ChildItem -File -Force -Recurse -Path $Env:WinDir 69.7240896 79.727841 72.81731518 +260 MB
    Get $Env:WinDir files with 32 largest lengths using -AsHashtable, Sort-Object 82.7488729 83.5245153 83.04068032 +1 GB
    Get $Env:WinDir files with 32 largest lengths using dictionary of by-length lists 81.6003697 82.7035483 82.15654538 +235 MB

    * 在Task ManagerDetails 选项卡 → Memory (active private working set) 列中观察到

    我有点失望,我的解决方案仅比使用 [Hashtable]Keys 的代码快约 1%,但可能使用已编译的 cmdlet 对文件进行分组,而不是对文件进行分组或排序,而是使用更多(解释)PowerShell 代码是一个洗牌。尽管如此,内存使用量的差异仍然很大,尽管我无法解释为什么 Get-ChildItem 调用简单地枚举所有文件最终会使用更多。

    【讨论】:

    • 不错。我觉得当前三个答案的组合是要走的路。使用 DictionaryEnumerateFilesmember enumeration 将在性能和内存方面胜过所有解决方案。
    • 很好的算法探索问题。关于基准测试我认为[GC]::Collect() 是不够的。 recommendation 似乎是这个序列:[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()。为了安全起见,我宁愿为每次运行启动一个新的 PowerShell 进程。
    猜你喜欢
    • 2018-02-28
    • 1970-01-01
    • 1970-01-01
    • 2023-03-25
    • 2017-08-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多