Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Passing Enumerables vs. Lists to a View has massive performance difference

Tags:

c#

asp.net-mvc

In a nutshell, I have a view which accepts:

@model IEnumerable<MyModel>

and goes on to cycle through the model, creating a table using:

    @for (int i = 0; i < Model.Count(); i++)
    {
        <tr id="tblRow_@(Model.ElementAt(i).Id)">
            <td width="1">
                @Html.DisplayFor(m => m.ElementAt(i).LoggedInUser)
            </td>
            <td width="1" class="date">
                @Html.DisplayFor(m => m.ElementAt(i).DateCreated)
            </td>
        </tr>
    }

So in the controller I pass x to the view, which is either:

var x = new DAL().GetList(); // returns IEnumerable<MyModel>

or

var x = new DAL().GetList().ToList(); 

Passing the 1st (IEnumerable) is waaayy slower than passing the 2nd (converted to List already)

Why?

I'm assuming it's something to do with Model.Count() and that maybe it has to convert the IEnumerable to a List for every cycle, but even with just 100 entries the speed difference goes from almost immediate to like, 8 seconds.

like image 628
jamheadart Avatar asked Jan 26 '23 03:01

jamheadart


1 Answers

TL;DR: Use foreach instead. See code at the bottom.

This is mostly not about ASP.NET at all - it's the way that IEnumerable<T> and the extension methods on it work, in particular with respect to lazily created sequences.

When you call ToList() on a sequence, that creates a List<T> by asking for each element of the original sequence - but after that, you can do everything (accessing by index, getting the count etc) without consulting the sequence at all. Not only does it not need to consult the sequence, but LINQ has optimizations for ElementAt() and Count() when they're called on IList<T> implementations.

If you call Count() on an IEnumerable<T> which doesn't implement IList<T> (or any of a few other interfaces that help) it has to loop through the entire sequence from the start, until it gets to the end. If the sequence is lazily evaluated (e.g. using an iterator block with yield statements, that means doing work again.

ElementAt() is similar, except it doesn't have to get to the very end - it just needs to get to the specified element.

Here's a complete console app that demonstrates the issue quite clearly - run it carefully and make sure you understand the output:

using System;
using System.Collections.Generic;
using System.Linq;

class Test
{
    static void Main()
    {
        var sequence = CreateSequence();
        ConsumeList(sequence);
        ConsumeSequence(sequence);
   }

    static void ConsumeList(IEnumerable<int> sequence)
    {
        Console.WriteLine("Start of ConsumeList");
        var list = sequence.ToList();
        Console.WriteLine("ToList has completed - iterating");
        for (int i = 0; i < list.Count(); i++)
        {
            var element = list.ElementAt(i);
            Console.WriteLine($"Element {i} is {element}");
        }
        Console.WriteLine("End of ConsumeList");
        Console.WriteLine();
    }

    static void ConsumeSequence(IEnumerable<int> sequence)
    {
        Console.WriteLine("Start of ConsumeSequence");
        var list = sequence.ToList();
        for (int i = 0; i < sequence.Count(); i++)
        {
            var element = sequence.ElementAt(i);
            Console.WriteLine($"Element {i} is {element}");
        }
        Console.WriteLine("End of ConsumeSequence");
    }

    static IEnumerable<int> CreateSequence()
    {
        for (int i = 0; i < 5; i++)
        {
            var value = i * 2;
            Console.WriteLine($"Yielding {value}");
            yield return value;
        }
    }
}

This doesn't mean you need to call ToList() though - your whole loop can be rewritten to avoid using Count() and ElementAt entirely:

@foreach (var element in Model)
{
    <tr id="tblRow_@(element.Id)">
        <td width="1">
            @Html.DisplayFor(m => element.LoggedInUser)
        </td>
        <td width="1" class="date">
            @Html.DisplayFor(m => element.DateCreated)
        </td>
    </tr>
}

Now the tricky part there is whether DisplayFor will do the right thing. It's possible that it won't - I don't know enough about HtmlHelper<T> to know exactly what's going on there. You may need to change the code a little bit more for that to work.

If you do need to access the element by index there, I'd change the model to a List<T> and use the Count property and the regular indexer instead:

@for (int i = 0; i < Model.Count; i++)
{
    <tr id="tblRow_@(Model[i].Id)">
        <td width="1">
            @Html.DisplayFor(m => m[i].LoggedInUser)
        </td>
        <td width="1" class="date">
            @Html.DisplayFor(m => m[i].DateCreated)
        </td>
    </tr>
}

That way you don't have a hidden "maybe it'll be quick, maybe it won't" dependency.

like image 68
Jon Skeet Avatar answered Feb 02 '23 01:02

Jon Skeet