Blog TOCThis example will use some of the syntactic constructs that are presented in detail further on in this tutorial. Don't worry if this example contains code that you don't understand; it is presented so that you can see the big picture of the comparison of the two styles. Later, after you have read through the rest of the tutorial, if necessary return to these examples and review them. In this topic, we're more concerned with seeing the big picture.

The example will consist of two separate transformations. The problem that we want to solve is to first increase the contrast of an image, and then lighten it. So we want to first brighten the brighter pixels, and darken the darker pixels. Then, after increasing contrast, we want to increase the value of each pixel by a fixed amount. (I'm artificially dividing this problem into two phases. Of course, in a real world situation, you would solve this in a single transformation, or perhaps using a transform specified with a matrix).

To further simplify the mechanics of the transform, for the purposes of this example, we'll use a single floating point number to represent each pixel. And we'll write our code to manipulate pixels in an array, and disregard the mechanics of dealing with image formats.

So, in this first example, our problem is that we have an array of 10 floating point numbers. We'll define that black is 0, and pure white is 10.0.

The first transform – increase the contrast: if the pixel is above five, we'll increase the value by 1.5 * (p – 5). If the pixel is below 5, we'll decrease the value by (p – 5) * 1.5. Further, we'll limit the range – a pure white pixel can't get any brighter and a pure black pixel can't get any darker.

The second transform – brighten the image: we'll add 1.2 to every pixel, again capping the value at 10.0.

When coding in a traditional, imperative style, it would be a common approach to modify the array in place, so that is how the following example is coded. The example prints the pixel values to the console three times – unmodified, after the first transformation, and after the second transformation.

However, there are significant differences. In the second example, we did not modify the original array. Instead, we defined a couple of queries for the transformation. Also, in the second example, we never actually produced a new array that contained the modified values. The queries operate in a lazy fashion, and until the code iterated over the results of the query, nothing was computed.

Here is the same example, presented using queries that are written using method syntax (Example #3):

This ability to just tack the second Select on the end of the first one is an example of composability. Another name for composability is malleability. How much can we add/remove/inject/surround code with other code without encountering brittleness? Malleability allows us to shape the results of our query.

All three of the above approaches that were implemented using queries have the same semantics, and same performance profile. The code that the compiler generates for all three is basically the same.

'All three of the above approaches that were implemented using queries have the same semantics, and same performance profile. The code that the compiler generates for all three is basically the same.'

I beg to differ.

The 'algorithmic' code would look similar or the same, but the underlying metaphor between the first example and the next two is large. By requiring that copies be made of the initial array, there is an underlying churn being induced in the heap. A churn which requires garbage collection of those self-same constructs. In this meager example those affects are relatively minor, but as things scale in complexity and size, those affects can become major. That makes the difference larger than it might first appear.

Memory churn scales with the number of compositions and the size of the intermediate result sets. This is not unlike some of the issues exhibited by SQLs during execution, which have some marked (and quite nasty) side effects.

I think that maybe you misunderstood which queries I was referring to. Example #1 is the algorithmic approach, which is has a completely different performance profile from examples #2, #3, and #4, which are implemented via queries. (I've labeled the examples above so that what I'm referring to is clear.) #2 is implemented with query expressions, which are translated by the compiler into calls to extension methods. Example #3 is the same query expressed in method syntax. Example #4 is the same as #3, except it has the last Select tacked onto the end. #2, #3, and #4 have the same performance profile.

It is true that the queries induce a larger number of short-lived objects on the heap. The garbage collector is optimized for handling many short-lived objects. I have regularly used code similar to the final results of this tutorial on a set of documents that are fairly large: > 200 documents, each approx 50K in size. The query code executes for all 200 of the documents in about 2 seconds. The performance is very good.

Regarding #2, #3, and #4, they don't create intermediate result sets as such, due to lazy evaluation. So even if the source array was extremely large, the amount of long-term memory used doesn't increase.

I absolutely agree that there are certain scenarios where intruducing a large number of objects on the heap would result in unacceptible perf. But in those scenarios, you might choose another technology, such as C or C++. If you were processing XML and need good perf on extremely large XML documents, you may use a streaming parser such as SAX or XmlLite.

One of the ideas behind LINQ is that we have these incredibly powerful computers, and in many circumstances, we can use the power of the computer to make the developer's job easier. We don't care whether the resulting code runs in .02 seconds or in 2 seconds, if the developer was able to write the code much faster.

Does this make sense?

-Eric

R King

30 Apr 2008 2:28 PM

Eric,

It does indeed make sense.

Let me give you a little of my background.. I've worked for 25+ years, many of them doing very large scalable systems. In the last 10 years or so I've been involved in hiring engineers, and I've run into a very large number of engineers that don't take these issues into account, even when performance of websites and such depends on such things. I've worked in C++, Java, and for the last year C#. At one level or another all these systems suffer from heap churn if you don't pay attention to what you are doing. Its why I pointed out the issue.

Thanks again for your continued thoughtful responses, and your contribution to making the .NET environment and its underlying environment the easy thing to use that it is. I frequent your blog and find it most illuminating.. :)

It should be mentioned that deferred execution makes last example the most effective. Printing result before and after the last selecting generates two iterations through the array while last example will scan array only once.

Deferred execution generates another trap for novices: there is no need following style, presented in the third example. Removing intermediate printing from second example will create code equivalent to the third one.