Estaba probando una aplicación .NET en una RaspberryPi y mientras que cada iteración de ese programa tomó 500 milisegundos en una computadora portátil con Windows, lo mismo tomó 5 segundos en una RaspberryPi. Después de algunas depuraciones, descubrí que la mayor parte de ese tiempo se dedicaba a un bucle foreach concatenando cadenas.

Edición 1: Para aclarar, ese tiempo de 500 ms y 5 s que mencioné fue el tiempo de todo el ciclo. Coloqué un temporizador antes del bucle y detuve el temporizador después de que el bucle terminó. Y el número de iteraciones es el mismo en ambos, 1000.

Edición 2: para cronometrar el ciclo, utilicé la respuesta mencionada aquí.

private static string ComposeRegs(List<list_of_bytes> registers)
{
    string ret = string.Empty;
    foreach (list_of_bytes register in registers)
    {
        ret += Convert.ToString(register.RegisterValue) + ",";
    }
    return ret;
}

De la nada, reemplacé el foreach con un bucle for, y de repente comienza a tomar casi el mismo tiempo que en esa computadora portátil. 500 a 600 milisegundos.

private static string ComposeRegs(List<list_of_bytes> registers)
{
    string ret = string.Empty;
    for (UInt16 i = 0; i < 1000; i++)
    {
        ret += Convert.ToString(registers[i].RegisterValue) + ",";
    }
    return ret;
}

¿Debería usar siempre bucles for en lugar de foreach? ¿O fue solo un escenario en el que un bucle for es mucho más rápido que un bucle foreach?

0
Usman Mehmood 26 nov. 2021 a las 13:11
2
spent in a foreach loop concatenating strings. ese es tu problema, no for vs foreach. Las cadenas son inmutables. La modificación o concatenación de cadenas crea una nueva cadena. Sus bucles crearon 2000 objetos temporales que deben recolectarse como basura. Ese proceso es caro . En su lugar, use un StringBuilder, preferiblemente con un capacity aproximadamente igual al tamaño de la cadena esperada
 – 
Panagiotis Kanavos
26 nov. 2021 a las 13:16
2
En cuanto a por qué hay tanta diferencia, ¿estás seguro de que hay una? ¿Qué mediste realmente? ¿Está seguro de que el GC no se ejecutó durante la prueba? Para obtener números significativos, use BenchmarkDotNet para ejecutar cada código las veces que se estabilice. los resultados y tienen en cuenta la GC y las asignaciones
 – 
Panagiotis Kanavos
26 nov. 2021 a las 13:17
3
Otra diferencia obvia entre sus dos métodos es que el segundo aborta después de 1000 elementos, sin importar cuántos haya en la lista, y explota si hay menos de 1000. El primero siempre procesa la lista completa, por lo que dependiendo de cuántos elementos haya en la lista, pueden estar haciendo cantidades de trabajo muy diferentes.
 – 
Damien_The_Unbeliever
26 nov. 2021 a las 13:21
1
Entonces, la prueba es incorrecta y completamente vulnerable a los retrasos de GC. No estás midiendo lo que crees que eres. Utilice BenchmarkDotNet para obtener resultados significativos
 – 
Panagiotis Kanavos
26 nov. 2021 a las 13:22
1
Al mismo tiempo, agregue otra prueba para StringBuilder. Sospecho que se sorprenderá. ¡¡¡¡500ms por solo 1000 artículos es terriblemente, increíblemente lento !!!!! Un RPi tiene un núcleo de 1 + GHz, ¿cómo puede llevar tanto tiempo formatear 1000 elementos? ¡Son tan pocos datos que deberían caber incluso en la memoria caché de la CPU del RPi! No importa la máquina de Windows.
 – 
Panagiotis Kanavos
26 nov. 2021 a las 13:26

2 respuestas

La mejor respuesta

El problema real es concatenar cadenas, no una diferencia entre for vs foreach. Los tiempos informados son terriblemente lentos incluso en una Raspberry Pi. 1000 elementos son tan pocos datos que pueden caber en la memoria caché de la CPU de cualquier máquina. Un RPi tiene una CPU de 1+ GHZ, lo que significa que cada concatenación toma al menos 1000 ciclos.

El problema es la concatenación. Las cadenas son inmutables. La modificación o concatenación de cadenas crea una nueva cadena. Sus bucles crearon 2000 objetos temporales que deben recolectarse como basura. Ese proceso es caro . Utilice un StringBuilder en su lugar, preferiblemente con un capacity aproximadamente igual al tamaño de la cadena esperada.

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder(registers.Count * 3);
        foreach (list_of_bytes register in registers)
        {
            sb.AppendFormat("{0}",register.RegisterValue);
        }
        return sb.ToString();
    }

Simplemente medir una sola ejecución, o incluso promediar 10 ejecuciones, no producirá números válidos. Es muy posible que el GC se ejecute para recolectar esos 2000 objetos durante una de las pruebas. También es muy posible que una de las pruebas se haya retrasado por la compilación JIT o por cualquier otra razón. Una prueba debe ejecutarse lo suficiente para producir números estables .

El estándar de facto para la evaluación comparativa de .NET es BenchmarkDotNet. Esa biblioteca ejecutará cada punto de referencia el tiempo suficiente para eliminar el efecto de inicio y enfriamiento y contabilizar las asignaciones de memoria y las colecciones de GC. Verá no solo cuánto toma cada prueba, sino cuánta RAM se usa y cuántos GC se causan

Para medir realmente su código, intente usar este punto de referencia usando BenchmarkDotNet:

[MemoryDiagnoser]
[MarkdownExporterAttribute.StackOverflow]
public class ConcatTest
{

    private readonly List<list_of_bytes> registers;


    public ConcatTest()
    {
        registers = Enumerable.Range(0,1000).Select(i=>new list_of_bytes(i)).ToList();
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder(registers.Count*3);
        foreach (var register in registers)
        {
            sb.AppendFormat("{0}",register.RegisterValue);
        }
        return sb.ToString();
    }

    [Benchmark]
    public string ForEach()
    {
        string ret = string.Empty;
        foreach (list_of_bytes register in registers)
        {
            ret += Convert.ToString(register.RegisterValue) + ",";
        }
        return ret;
    }

    [Benchmark]
    public string For()
    {
        string ret = string.Empty;
        for (UInt16 i = 0; i < registers.Count; i++)
        {
            ret += Convert.ToString(registers[i].RegisterValue) + ",";
        }
        return ret;
    }

}

Las pruebas se ejecutan llamando a BenchmarkRunner.Run<ConcatTest>()

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq;

public class Program
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<ConcatTest>();
        Console.WriteLine(summary);
    }
}

Resultados

Ejecutar esto en una Macbook produjo los siguientes resultados. Tenga en cuenta que BenchmarkDotNet produjo resultados listos para usar en StackOverflow, y la información del tiempo de ejecución se incluye en los resultados:

BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.2 (20G95) [Darwin 20.6.0]
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.100
  [Host]     : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT


        Method |      Mean |    Error |   StdDev |    Gen 0 |   Gen 1 | Allocated |
-------------- |----------:|---------:|---------:|---------:|--------:|----------:|
 StringBuilder |  34.56 μs | 0.682 μs | 0.729 μs |   7.5684 |  0.3052 |     35 KB |
       ForEach | 278.36 μs | 5.509 μs | 5.894 μs | 818.8477 | 24.4141 |  3,763 KB |
           For | 268.72 μs | 3.611 μs | 3.015 μs | 818.8477 | 24.4141 |  3,763 KB |

Tanto For como ForEach consumieron casi 10 veces más que StringBuilder y utilizaron 100 veces más RAM

2
Panagiotis Kanavos 26 nov. 2021 a las 14:22

Puede que este no sea el problema con el que está lidiando, pero si una cadena puede cambiar como en su ejemplo, entonces usar un StringBuilder es una mejor opción y podría ayudar realmente a mejorar el rendimiento.

Modificar cualquier objeto de cadena resultará en la creación de un nuevo objeto de cadena. Esto hace que el uso de cuerdas sea costoso. Entonces, cuando el usuario necesita las operaciones repetitivas en la cadena, entonces surge la necesidad de StringBuilder. Proporciona la forma optimizada de lidiar con las operaciones de manipulación de cadenas múltiples y repetitivas. Representa una cadena mutable de caracteres. Mutable significa la cadena que se puede cambiar. Entonces, los objetos String son inmutables, pero StringBuilder es el tipo de cadena mutable. No creará una nueva instancia modificada del objeto de cadena actual, pero hará las modificaciones en el objeto de cadena existente.

Entonces, en lugar de crear muchos objetos temporales que necesitarán ser recolectados como basura y significan que están ocupando mucha memoria, simplemente use StringBuilder.

Más sobre StringBuilder - https://docs.microsoft.com/en-us/dotnet/api/system.text.stringbuilder?view=net-6.0

2
Ran Turner 26 nov. 2021 a las 13:22
1
Eso no explica la diferencia. Ambos bucles son igualmente lentos. Lo más probable es que el código de medición esté midiendo cosas incorrectas.
 – 
Panagiotis Kanavos
26 nov. 2021 a las 13:21
"pero hacer las modificaciones en el objeto de cadena existente" - No es cierto. No hay ningún objeto de cadena existente.
 – 
Enigmativity
26 nov. 2021 a las 13:28
1
"StringBuilder es el tipo de cadena mutable": esto tampoco es cierto.
 – 
Enigmativity
26 nov. 2021 a las 13:29
"crear muchos objetos temporales que necesitarán ser recolectados como basura" - ¿Es ese el problema o es la creación de los objetos temporales en primer lugar el problema? En otras palabras, ¿tomaría mucho tiempo aún si no hubiera una CG?
 – 
Enigmativity
26 nov. 2021 a las 13:30