Skip to content

Chapter 13: Matrix Multiplication Basics in .NET

Theoretical Foundations

Matrix multiplication is the computational engine of modern AI, transforming raw data into higher-dimensional representations where relationships become linearly separable. While the mathematical operation is ancient, implementing it in a functional, declarative style using LINQ reveals the elegance of data pipelines that are both expressive and composable—essential for building robust AI preprocessing stages.

The Dot Product: A Functional Perspective

At its core, matrix multiplication is a series of dot products. Given two matrices, A (m x n) and B (n x p), the element at row i and column j of the resulting matrix C is the dot product of the i-th row of A and the j-th column of B.

In imperative programming, this requires nested loops. In functional programming with LINQ, we treat data as immutable streams. We don't "loop"; we project, filter, and aggregate.

Consider a real-world analogy: The Assembly Line Quality Check. Imagine a factory (Matrix A) producing parts (rows) with specific attributes (columns). Another factory (Matrix B) specifies the required attributes for assembly (columns). The dot product is the total compatibility score between a specific part and a specific assembly requirement. We don't manually count compatibility for every part; we set up a conveyor belt (pipeline) that automatically checks each part against the requirements.

Deferred vs. Immediate Execution: The Lazy Factory

Before calculating dot products, we must understand how LINQ processes data. This is the Critical Concept of this chapter.

Deferred Execution means the query is not executed when it is defined, but when the results are enumerated (e.g., in a foreach loop or by calling .ToList()). It is like writing a recipe but not cooking until you are hungry. Immediate Execution forces the query to run immediately and return a result (e.g., .ToList(), .ToArray(), .Sum()). It is like cooking a meal and putting it in the fridge for later.

In AI data pipelines, this distinction is vital. If you apply a heavy normalization filter to a 10GB dataset using deferred execution and then iterate over it multiple times, you re-process the 10GB every time. If you materialize it (Immediate Execution) once, subsequent iterations are fast.

Implementing Matrix Multiplication with LINQ

Let's implement matrix multiplication declaratively. We will avoid for loops entirely, relying on Select and SelectMany.

First, the setup. We represent a matrix as IEnumerable<IEnumerable<double>>. This allows for jagged arrays or lazy sequences, which is crucial for handling data streams that might not fit entirely in memory.

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

public class MatrixOperations
{
    // Calculates C = A * B
    public static IEnumerable<IEnumerable<double>> Multiply(
        IEnumerable<IEnumerable<double>> matrixA, 
        IEnumerable<IEnumerable<double>> matrixB)
    {
        // Convert B to a list for efficient column access (Immediate Execution on B)
        // This is a performance optimization: B is accessed multiple times.
        var bList = matrixB.Select(row => row.ToList()).ToList();

        int rowsA = matrixA.Count();
        int colsA = matrixA.First().Count();
        int colsB = bList.First().Count();

        // Validate dimensions
        if (matrixA.First().Count() != bList.Count)
            throw new ArgumentException("Inner matrix dimensions must agree.");

        // The outer Select projects each row of A into a new row of C
        return matrixA.Select((rowA, indexA) => 
        {
            // For each row in A, calculate the dot product with every column in B
            // We use a range to access columns by index
            return Enumerable.Range(0, colsB).Select(colIndex =>
            {
                // Access the specific column of B
                // In a purely functional approach without side effects, 
                // we map B's column index to the values.
                var columnB = bList.Select(rowB => rowB[colIndex]);

                // Calculate Dot Product: Sum of (A_row[i] * B_col[i])
                return rowA.Zip(columnB, (a, b) => a * b).Sum();
            });
        });
    }
}

Breaking Down the Functional Pipeline

  1. matrixA.Select((rowA, indexA) => ...): We iterate over the rows of Matrix A. The Select method here projects each row into a new sequence (the rows of the result matrix C). We use the overload with the index, though in this specific calculation we rely on the row data itself.

  2. Enumerable.Range(0, colsB).Select(colIndex => ...): Instead of a for loop for (int j = 0; j < colsB; j++), we generate a range of numbers and project them into column indices. This creates the columns of the result matrix C.

  3. var columnB = bList.Select(rowB => rowB[colIndex]): This extracts the j-th column from Matrix B. Since we converted B to a list of lists earlier, accessing by index is O(1). If we kept B as IEnumerable, this would be O(N) per access, significantly degrading performance.

  4. rowA.Zip(columnB, (a, b) => a * b).Sum(): This is the mathematical dot product.

    • Zip pairs the elements of the row from A and the column from B.
    • The lambda (a, b) => a * b performs the multiplication.
    • .Sum() aggregates the results into a single double.

The Danger of Side Effects in AI Pipelines

The prompt forbids side effects inside queries. Let's look at why this is critical in AI contexts, specifically Data Preprocessing.

Imagine we are normalizing a dataset (scaling values to 0-1) and we want to track the maximum value encountered for logging.

WRONG (Imperative with Side Effects):

double globalMax = 0;
// This violates functional purity
var normalized = data.Select(x => 
{
    if (x > globalMax) globalMax = x; // SIDE EFFECT: Modifying external variable
    return x / 100.0; 
});
Why this fails:

  1. Thread Safety: If we use PLINQ (.AsParallel()), multiple threads might write to globalMax simultaneously, causing race conditions and incorrect results.
  2. Deferred Execution Surprise: If normalized is deferred, globalMax might remain 0 until the list is actually enumerated. If the enumeration happens later in a different scope, the logic is broken.

CORRECT (Functional Approach): We separate the calculation of the maximum from the normalization.

// Immediate execution to find the scalar
double globalMax = data.Max(); 

// Pure transformation
var normalized = data.Select(x => x / globalMax);
Or, if we must do it in one pass (and performance is critical), we return a tuple or a new object containing both the result and the state, rather than mutating external variables.

PLINQ and Parallelism in Vector Processing

In AI, we often process massive vectors or batches of data. AsParallel() (PLINQ) allows us to utilize multi-core processors for the dot product calculations.

public static IEnumerable<IEnumerable<double>> MultiplyParallel(
    IEnumerable<IEnumerable<double>> matrixA, 
    IEnumerable<IEnumerable<double>> matrixB)
{
    var bList = matrixB.Select(row => row.ToList()).ToList();
    int colsB = bList.First().Count();

    // AsParallel() distributes the rows of A across available CPU cores
    return matrixA.AsParallel()
                  .Select(rowA => 
                  {
                      return Enumerable.Range(0, colsB).Select(colIndex =>
                      {
                          var columnB = bList.Select(rowB => rowB[colIndex]);
                          return rowA.Zip(columnB, (a, b) => a * b).Sum();
                      });
                  });
}
Architectural Implication: By simply adding .AsParallel(), we transform a linear algebra operation into a concurrent one. This is the power of declarative code: the what (multiply) remains the same, but the how (sequential vs. parallel) changes without rewriting the core logic. This is essential for scaling AI inference pipelines where batch processing of embeddings is required.

Connecting to Previous Concepts: LINQ to Objects

This builds directly on Book 2: Collections, where we learned to manipulate List<T> and IEnumerable<T>. In previous chapters, we might have used foreach to sum numbers. Here, we elevate that understanding. We treat the matrix not as a static grid of memory, but as a sequence of sequences. This abstraction allows us to swap the underlying storage (e.g., from an in-memory array to a database cursor or a file stream) without changing the multiplication logic, provided the sequence is readable.

Real-World AI Application: Data Preprocessing Pipelines

In AI, raw data is rarely ready for a model. It must be cleaned, normalized, and shuffled. LINQ pipelines are the perfect tool for this.

Scenario: Preparing a batch of data for a neural network input layer.

  1. Cleaning (Filtering): Remove invalid data points.
  2. Normalizing (Mapping): Scale values.
  3. Shuffling (Ordering): Randomize order to prevent model bias.
public class DataPipeline
{
    public static IEnumerable<float[]> PrepareBatch(IEnumerable<float[]> rawData, int batchSize)
    {
        // 1. CLEANING: Remove rows with missing values (NaN)
        // .Where filters the stream
        var cleanData = rawData.Where(row => !row.Contains(float.NaN));

        // 2. NORMALIZATION: Scale inputs between 0 and 1
        // .Select projects each row into a normalized row
        // Note: In a real scenario, we'd calculate min/max first (Immediate Execution)
        var maxVal = cleanData.Max(row => row.Max()); 
        var normalizedData = cleanData.Select(row => 
            row.Select(val => val / maxVal).ToArray()
        );

        // 3. SHUFFLING: Randomize the order
        // We use a seeded random to ensure reproducibility in training
        var rnd = new Random(42);
        var shuffled = normalizedData.OrderBy(x => rnd.Next());

        // 4. BATCHING: Group into chunks
        // .SelectMany with Index allows us to group by batch index
        var batched = shuffled
            .Select((value, index) => new { value, index })
            .GroupBy(x => x.index / batchSize)
            .Select(g => g.Select(x => x.value).ToList());

        return batched;
    }
}

Execution Analysis: When does this pipeline run? If we iterate over batched immediately, the pipeline executes:

  1. Where filters the raw data.
  2. Max runs (Immediate Execution inside the Select for normalization? No, maxVal is calculated before the Select starts).
  3. Select normalizes.
  4. OrderBy shuffles.
  5. GroupBy batches.

If we return batched without iterating, Deferred Execution holds. The raw data is not touched until the consumer (e.g., the training loop) asks for the first batch. This is memory efficient. However, because maxVal is calculated inside PrepareBatch, that specific part is immediate. To make the whole pipeline deferred, we would wrap the logic in IEnumerable blocks or Lazy<T>, but typically in AI, we materialize the normalization step because we need the global statistics (max/min) before processing the data.

Visualizing the Data Flow

The following diagram illustrates the functional pipeline concept. Unlike imperative nested loops which are tightly coupled, the LINQ pipeline is a linear flow of transformations.

This diagram visualizes the LINQ functional pipeline as a linear flow of data transformations, contrasting it with tightly coupled imperative nested loops.
Hold "Ctrl" to enable pan & zoom

This diagram visualizes the LINQ functional pipeline as a linear flow of data transformations, contrasting it with tightly coupled imperative nested loops.

Summary

In this section, we moved from the imperative mindset of nested loops to the declarative power of LINQ. We saw how:

  1. Matrix Multiplication is a composition of Select and Zip operations, mathematically equivalent to dot products.
  2. Deferred Execution allows us to define complex data transformations without consuming memory until necessary, a vital feature for large-scale AI datasets.
  3. Pure Functional Pipelines (avoiding side effects) ensure thread safety and predictability, especially when using PLINQ for parallel matrix operations.

This functional approach sets the stage for high-performance linear algebra, where the pipeline can be optimized by the runtime or distributed across clusters, all while maintaining clean, readable code.

Basic Code Example

Real-World Context: Data Preprocessing for a Recommendation Engine

Imagine you are building a simple recommendation system. You have a raw dataset of user interactions (clicks, views, purchases) stored as a list of objects. Before you can calculate similarity matrices or train a model, you must preprocess this data. This involves:

  1. Cleaning: Filtering out invalid or irrelevant entries (e.g., users who haven't interacted enough).
  2. Normalizing: Transforming raw values (like timestamps or purchase amounts) into a standard range.
  3. Shuffling: Randomizing the order to prevent bias during batch processing.

In this example, we will simulate a dataset of user interactions and apply these three steps using a Pure Functional Pipeline. We will strictly use LINQ to demonstrate Deferred Execution versus Immediate Execution.

The Code Example

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

public class DataPreprocessing
{
    // Represents a raw user interaction event
    public record UserInteraction(string UserId, int ItemId, double RawValue, DateTime Timestamp);

    public static void Main()
    {
        // 1. Simulate Raw Data (Dirty, Unsorted)
        var rawData = new List<UserInteraction>
        {
            new("UserA", 101, 500.0, DateTime.Now.AddDays(-10)),
            new("UserB", 102, 0.0,   DateTime.Now.AddDays(-5)),  // Invalid: RawValue is 0
            new("UserA", 103, 250.0, DateTime.Now.AddDays(-2)),
            new("UserC", 101, 100.0, DateTime.Now.AddDays(-1)),
            new("UserB", 104, 750.0, DateTime.Now.AddDays(-8)),  // Invalid: RawValue > 700 (outlier)
            new("UserA", 101, 300.0, DateTime.Now.AddDays(-3))
        };

        Console.WriteLine("--- Step 1: Defining the Query (Deferred Execution) ---");

        // 2. Define the Functional Pipeline
        // This query is NOT executed yet. It is a blueprint of instructions.
        var preprocessingPipeline = rawData
            .Where(interaction => interaction.RawValue > 0 && interaction.RawValue <= 700) // Clean: Filter outliers/zeros
            .Select(interaction => interaction with 
            { 
                // Normalize: Scale RawValue to a 0.0-1.0 range (assuming max is 700)
                RawValue = interaction.RawValue / 700.0 
            })
            .OrderBy(interaction => Guid.NewGuid()); // Shuffle: Randomize order

        Console.WriteLine("Query defined. No processing has occurred yet.");
        Console.WriteLine($"Type of 'preprocessingPipeline': {preprocessingPipeline.GetType().Name}\n");

        // 3. Immediate Execution (Materialization)
        // The pipeline executes here. We iterate over the results to force execution.
        // .ToList() creates a concrete list in memory.
        var processedData = preprocessingPipeline.ToList();

        Console.WriteLine("--- Step 2: Execution Results ---");
        Console.WriteLine($"Original Count: {rawData.Count}");
        Console.WriteLine($"Processed Count: {processedData.Count} (2 invalid items removed)\n");

        // 4. Displaying the processed data
        // We use a functional projection to format the output string
        var outputLines = processedData
            .Select((item, index) => $"{index + 1}. User: {item.UserId}, Item: {item.ItemId}, Normalized Value: {item.RawValue:F4}")
            .ToList();

        outputLines.ForEach(Console.WriteLine);
    }
}

Explanation of the Pipeline

  1. Data Simulation: We create a List<UserInteraction> representing raw logs. Note that two entries are "dirty" (one has a value of 0.0, another exceeds our threshold of 700.0).
  2. The Query Definition: The variable preprocessingPipeline is assigned a chain of LINQ operators.
    • .Where: Filters the collection based on a predicate. We exclude invalid data points immediately.
    • .Select: Projects the data. Here, we use the with expression (a functional record feature) to create a new object with a normalized RawValue. This is a pure transformation; it does not modify the original rawData.
    • .OrderBy: We use Guid.NewGuid() as a sort key. This is a functional trick to simulate shuffling (randomization) without imperative loops.
  3. Deferred Execution: Crucially, at this stage, no iteration has occurred. The code inside the lambdas (the logic inside =>) has not run yet. The preprocessingPipeline variable holds the plan, not the result.
  4. Immediate Execution: The call to .ToList() triggers the pipeline. The system iterates through rawData, applies the Where filter, transforms the items with Select, sorts them, and finally creates a new List containing the results.
  5. Output Projection: To display the results, we define another query using .Select with an index parameter to format the strings, and .ForEach (which is an immediate execution method on List) to print them.

Visualizing the Data Flow

The following diagram illustrates how data moves through the functional pipeline, highlighting the separation between the query definition and the materialization step.

Diagram: G
Hold "Ctrl" to enable pan & zoom

Common Pitfalls

The Trap of Deferred Execution and Side Effects

A frequent mistake when working with LINQ pipelines is assuming execution happens immediately, or attempting to modify external state within a query.

Example of the Mistake:

int counter = 0;
// DANGER: This query is deferred. The counter variable is captured.
var badQuery = rawData.Select(x => {
    counter++; // SIDE EFFECT: Modifying external variable
    return x with { RawValue = x.RawValue * 2 };
});

// At this point, 'counter' is still 0. The query hasn't run.
Console.WriteLine(counter); // Output: 0

// Execution happens here
var results = badQuery.ToList(); 

// Now 'counter' might be 5, but relying on this is dangerous and non-thread-safe.
Console.WriteLine(counter); // Output: 5

Why this is wrong:

  1. Unpredictability: If you use PLINQ (.AsParallel()), the counter variable would be accessed by multiple threads simultaneously, causing race conditions and incorrect counts.
  2. Broken Logic: If you try to use counter inside the query (e.g., x => x.Id == counter), the value captured is the value at the moment of execution, not definition, which often leads to bugs.
  3. Performance: If you iterate over the query multiple times (e.g., once to count, once to process), the side effect (like counter++) runs multiple times, polluting your data.

The Fix: Always treat LINQ queries as pure functions. Input should equal output, with no observable side effects. If you need to count or aggregate, do it explicitly after the query or use a specific operator like .Count() or .Sum() which are designed for that purpose.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.