PowerShell:将 16MB CSV 导入 PowerShell 变量会创建超过 600MB 的 PowerShell 内存使用量

Dav*_*atz 5 memory powershell memory-profiling pscustomobject import-csv

我试图理解为什么当我导入一个大约 16MB 的文件作为变量时 PowerShell 的内存膨胀这么多。我可以理解围绕该变量存在额外的内存结构,但我只是想了解它为什么那么高。这是我在下面所做的 - 只是任何人都可以运行的另一个脚本的精简片段。

笔记/问题

  1. 不是抱怨,而是试图理解为什么使用如此多,以及是否有更好的方法来做到这一点或更有效地管理内存以尊重我正在运行的系统。
  2. 在 PowerShell 5.1 和刚刚发布的 PowerShell 7、RC3 中也会出现相同的行为。我不认为这是一个错误,只是让我了解更多信息的另一个机会。
  3. 我的总体目标是运行一个 foreach 循环来检查另一个小得多的数组是否与此数组匹配或缺少匹配。

我的测试代码

Invoke-WebRequest -uri "http://s3.amazonaws.com/alexa-static/top-1m.csv.zip" -OutFile C:\top-1m.csv.zip

Expand-Archive -Path C:\top-1m.csv.zip -DestinationPath C:\top-1m.csv

$alexaTopMillion = Import-Csv -Path C:\top-1m.csv
Run Code Online (Sandbox Code Playgroud)

对任何回答这个问题的人:感谢您的时间并帮助我每天学习更多!

mkl*_*nt0 8

一般来说iRon在评论问题中的建议值得关注(具体问题在此后的部分中解决):

为了保持较低的内存使用率,在管道中使用对象而不是首先在内存中收集它们- 如果可行:

也就是说,而不是这样做:

# !! Collects ALL objects in memory, as an array.
$rows = Import-Csv in.csv
foreach ($row in $rows) { ... }
Run Code Online (Sandbox Code Playgroud)

做这个:

# Process objects ONE BY ONE.
# As long as you stream to a *file* or some other output stream
# (as opposed to assigning to a *variable*), memory use should remain constant,
# except for temporarily held memory awaiting garbage collection.
Import-Csv in.csv | ForEach-Object { ... } # pipe to Export-Csv, for instance
Run Code Online (Sandbox Code Playgroud)

但是,即使这样,您似乎也可能会因非常大的文件而耗尽内存- 请参阅此问题- 可能与尚未被垃圾收集的不再需要的对象的内存积累有关;因此,周期性地调用[GC]::Collect()ForEach-Object脚本块可以解决这个问题。


如果您确实需要立即收集Import-Csv 内存中输出的所有对象:

您观察到的过度内存使用来自[pscustomobject]实例(Import-Csv的输出类型)的实现方式,如本 GitHub 问题中所述(强调已添加):

内存压力最有可能来自PSNoteProperty[这是如何[pscustomobject]实现属性]的成本。每个PSNoteProperty都有 48 字节的开销,所以当你只为每个属性存储几个字节时,这会变得很大

同一问题提出了一种减少内存消耗的解决方法(也如Wasif Hasan 的回答所示):

  • 阅读第一CVS行和动态创建一个自定义类,代表行,并使用Invoke-Expression

    • 注意:虽然在这里使用它是安全的,Invoke-Expression但通常应避免使用

    • 如果您事先知道列结构,您可以创建一个自定义class的常规方式,这也允许您为属性使用适当的数据类型(否则默认为所有字符串);例如,将适当的属性定义为[int]( System.Int32) 进一步减少了内存消耗。

  • 管道Import-Csv到一个ForEach-Object调用,该调用将每个[pscustomobject]创建的类转换为动态创建的类的实例,从而更有效地存储数据。

注意:这种变通方法的代价是大大降低了执行速度

$csvFile = 'C:\top-1m.csv'

# Dynamically define a custom class derived from the *first* row
# read from the CSV file.
# Note: While this is a legitimate use of Invoke-Expression, 
#       it should generally be avoided.
"class CsvRow { 
 $((Import-Csv $csvFile | Select-Object -first 1).psobject.properties.Name -replace '^', '[string] $$' -join ";") 
}" | Invoke-Expression

# Import all rows and convert them from [pscustomobject] instances 
# to [CsvRow] instances to reduce memory consumption.
# Note: Casting the Import-Csv call directly to [CsvRow[]] would be noticeably
#       faster, but increases *temporary* memory pressure substantially.
$alexaTopMillion = Import-Csv $csvFile | ForEach-Object { [CsvRow] $_ }
Run Code Online (Sandbox Code Playgroud)

从长远来看,更好的解决方案也将更快,Import-Csv支持输出具有给定输出类型的已解析行,例如,通过-OutputType参数,如此 GitHub 问题中提议的
如果您对此感兴趣,请在那里表明您对提案的支持。


内存使用基准:

以下代码将内存使用与正常Import-Csv导入([pscustomobject]s数组)与解决方法(自定义类实例数组)进行比较。

测量并不准确,因为只是查询 PowerShell 的进程工作内存,这可以显示后台活动的影响,但它可以粗略地了解使用自定义类需要多少内存。

示例输出,它显示自定义类解决方法只需要大约五分之一的内存,下面使用了大约 166,000 行的示例 10 列 CSV 输入文件 - 具体比例取决于输入行和列的数量:

# !! Collects ALL objects in memory, as an array.
$rows = Import-Csv in.csv
foreach ($row in $rows) { ... }
Run Code Online (Sandbox Code Playgroud)

基准代码:

# Create a sample CSV file with 10 columns about 16 MB in size.
$tempCsvFile = [IO.Path]::GetTempFileName()
('"Col1","Col2","Col3","Col4","Col5","Col6","Col7","Col8","Col9","Col10"' + "`n") | Set-Content -NoNewline $tempCsvFile
('"Col1Val","Col2Val","Col3Val","Col4Val","Col5Val","Col6Val","Col7Val","Col8Val","Col9Val","Col10Val"' + "`n") * 1.662e5 |
  Add-Content $tempCsvFile

try {

  { # normal import
    $all = Import-Csv $tempCsvFile
  },
  { # import via custom class
    "class CsvRow {
      $((Import-Csv $tempCsvFile | Select-Object -first 1).psobject.properties.Name -replace '^', '[string] $$' -join ";")
    }" | Invoke-Expression
    $all = Import-Csv $tempCsvFile | ForEach-Object { [CsvRow] $_ }
  } | ForEach-Object {
    [gc]::Collect(); [gc]::WaitForPendingFinalizers() # garbage-collect first.
    $before = (Get-Process -Id $PID).WorkingSet64
    # Execute the command.
    & $_
    # Measure memory consumption and output the result.
    [pscustomobject] @{
      'MB Used' = ('{0,4:N2}' -f (((Get-Process -Id $PID).WorkingSet64 - $before) / 1mb)).PadLeft(7)
      Command = $_
    }
  }

} finally {
  Remove-Item $tempCsvFile
}
Run Code Online (Sandbox Code Playgroud)