2

I'm new to Haskell. As a part of an academic course, I was requested to implement a function in Haskell that calculates the determinant, rank and inverse matrix of a given matrix.

I use gaussian elimination (performing same row operations on both the original matrix and another matrix which is initialized as the identity matrix). I'm using a one-pass approach to accumulate to rank and determinant "on the fly".

The function behaves as expected for input matrices up to 600x600 (execution time in this case is 63 seconds). Any input above that size causes excessive memory usage and impractical computation time.

The inputs are 3 different matrices, sizes 800x800, 800x800 and 1000x1000.

Worth mentioning I am not allowed to use any external libraries (such as HMatrix or Data.Matrix)

I did my best to optimize the code, but since I'm new I wasn't successful at making my code work for sizes larger than 600x600.

import Data.Time.Clock
import System.IO

type Matrix = [[Double]]

-- Row operations

swapRows :: Int -> Int -> Matrix -> Matrix
swapRows i j mat = take i mat ++ [mat !! j] ++ (drop (i+1) . take j $ mat) ++ [mat !! i] ++ drop (j+1) mat

scaleRow :: Int -> Double -> Matrix -> Matrix
scaleRow i k mat = take i mat ++ [map (* k) (mat !! i)] ++ drop (i+1) mat

addRows :: Int -> Int -> Double -> Matrix -> Matrix
addRows i j k mat = take i mat ++ [zipWith (+) (mat !! i) (map (* k) (mat !! j))] ++ drop (i+1) mat

-- Gaussian elimination

eliminate :: Matrix -> (Matrix, Matrix, Double, Int)
eliminate mat = foldr eliminateRow (mat, identityMatrix (length mat), 1.0, rank) [0..length mat-1]
  where
    rank = length mat  -- Initialize rank as the number of rows

    eliminateRow row (mat, invMat, detAccum, rank) = foldl eliminateEntry (mat, invMat, detAccum, rank) [0..length mat-1]
      where
        eliminateEntry (mat, invMat, detAccum, rank) col
          | row == col = (scaleRow row (1 / pivot) mat, scaleRow row (1 / pivot) invMat, detAccum * pivot, rank)
          | mat !! col !! row /= 0 = (addRows col row (-ratio) mat, addRows col row (-ratio) invMat, detAccum, rank)
          | otherwise = (mat, invMat, detAccum, rank - 1)
          where
            pivot = mat !! row !! row
            ratio = (mat !! col !! row) / pivot

-- Matrix operations

identityMatrix :: Int -> Matrix
identityMatrix n = [[fromIntegral (fromEnum (i == j)) | j <- [1..n]] | i <- [1..n]]

-- create sub matrix n x n from matrix mat
subMatrix :: [[Double]] -> Int -> [[Double]]
subMatrix mat n = take n (map (take n) mat) 

-- Testing

main :: IO ()
main = do
  --let submat = [[1, 2], [3, 4]]  -- 3x3 invertible matrix
  
  contenm_headMulScal <- readFile "det_matrix(800 x 800).txt"
  let mat = map (map read . words) (lines contenm_headMulScal) :: [[Double]]
  
  let piece_dim = 600
  let submat = subMatrix mat piece_dim
  
  tt_start <- getCurrentTime
  let (refMat, inverse, determinant, rank) = eliminate submat  -- Calculate the ref matrix, determinant, and rank
  t_end <- getCurrentTime
  --putStrLn "Original Matrix:"
  --printMatrix submat
  putStrLn "\nDeterminant:"
  print determinant
  putStrLn "\nInverse Matrix:"
  --printMatrix inverse
  putStrLn $ "First element in the inverse matrix: " ++ show (head (head inverse))
  putStrLn "\nRank:"
  print rank
  tt_end <- getCurrentTime
  
  print "Runtime:"
  print (diffUTCTime tt_end tt_start)

printMatrix :: Matrix -> IO ()
printMatrix mat = mapM_ (putStrLn . unwords . map show) mat

For example, I take a "piece" of 600x600 out of the 800x800 input as you can see. It works. take a larger piece (700x700, or all the input) and it become impractical - about an hour of calculation in which the computer is totally stuck due to excessive memory usage.

Thanks to Daniel Wagner for pointing out: For folks who want to play along at home, try:

countingMatrix :: Int -> Matrix
countingMatrix n = [[fromIntegral (j*n+i) | j <- [1..n]] | i <- 
[1..n]] 

in place of the matrix loaded from disk.

I would appreciate any suggestions.

Menish
  • 21
  • 3
  • 1
    Can you show what you tried and what is not working? – Willem Van Onsem Jun 15 '23 at 19:54
  • Yes, I'm sorry. I'm new to this forum too. The kind mod edited my post so the code now appears in the question body. Thanks and sorry. – Menish Jun 15 '23 at 20:10
  • 2
    For folks who want to play along at home, try `countingMatrix :: Int -> Matrix; countingMatrix n = [[fromIntegral (j*n+i) | j <- [1..n]] | i <- [1..n]]` in place of the matrix loaded from disk. – Daniel Wagner Jun 15 '23 at 22:50
  • 1
    Typically one uses LU decomposition to get the matrix into a form that allows determining the determinant, inverse, etc.: https://en.wikipedia.org/wiki/LU_decomposition this may look like doing some non-related work, but it turns out that in LU-form, most matrix properties can be determined quite fast. – Willem Van Onsem Jun 16 '23 at 13:20
  • Thanks for your suggestion, Willem! I'll explore this approach. – Menish Jun 16 '23 at 14:52
  • 1
    `!!` is not your friend. It is linear in the index argument, so when algorithms are naïvely translated from arrays to lists, performance is usually bad. Try to imagine that `!!` does not exist, and avoid indices in general. Lists are meant to be mapped, folded, zipped, ... but not indexed. – n. m. could be an AI Jun 16 '23 at 20:25
  • Thank you, n.m. ! I will try to implement it without using !! - will update if that worked out :) – Menish Jun 16 '23 at 20:35
  • @n.m.willseey'allonReddit I have introduced some changes to my code following your advice. I have switched to a mutable representation of a matrix and now access to elements is done without using '!!' operator. This change introduced a dramatic impact, runs for half of the original time now. I can't thank you enough! – Menish Jun 17 '23 at 18:57

1 Answers1

3

Looks like there's a space leak/laziness issue. I don't blame you: I found it surprisingly finicky to get the evaluation behavior I wanted, even when I was already pretty sure what the problem was!

The thing you want to make sure of is that when making a new matrix, you don't hold onto any references to the old matrix. If you do, then the GC can't collect the old matrix. In particular, references include latent calculations like take i oldMatrix or oldMatrix !! i or similar. So! Let's discuss the changes I had to make to get this to happen.

First: when we make a new matrix that's mostly a copy of rows from the old matrix, but one or two new rows, we'll want a way to say "walk through the entire matrix, and force enough of its computation that you copy a pointer to a specific row, rather than a calculation that looks up that row in the old matrix later". To get this, we'll make a function that forces each element of a list, but only to WHNF. Note that, since we're passing this a matrix, this isn't a full evaluation! We don't force all the way down to matrix elements, only down to the level of matrix rows. On my first go I got this wrong, forcing all the way down to the element level, and inadvertently introduced a very serious time performance regression.

seqElements :: [a] -> [a]
seqElements [] = []
seqElements as@(a:at) = a `seq` seqElements at `seq` as

We will call this at the start of each of the row operations.

swapRows i j mat = seqElements $ {- old implementation -}
scaleRows i k mat = seqElements $ {- old implementation -}
addRows i j k mat = seqElements $ {- old implementatiotn -}

This forms the "base case" of our manual forcing annotations. Unfortunately, we now need to propagate this all the way up the expression tree -- each caller needs to make sure that any data structures it makes with one of these in a field also ties evaluation of that data structure to evaluation of the field. In eliminateEntry, that means we need a stricter version of quadruples.

quadruple a b c d = a `seq` b `seq` c `seq` d `seq` (a, b, c, d)
-- then, later, we replace (,,,) with quadruple:
        eliminateEntry (mat, invMat, detAccum, rank) col
          | row == col = quadruple (scaleRow row (1 / pivot) mat) (scaleRow row (1 / pivot) invMat) (detAccum * pivot) rank
          | mat !! col !! row /= 0 = quadruple (addRows col row (-ratio) mat) (addRows col row (-ratio) invMat) detAccum rank
          | otherwise = quadruple mat invMat detAccum (rank - 1)

Finally, in eliminateRow, I recommend switching from foldl to foldl'. It doesn't appear to make a difference in this case in my tests, but it's a good habit to get into; the extra laziness that foldl provides is almost never needed and frequently detrimental.

    eliminateRow row (mat, invMat, detAccum, rank) = foldl' eliminateEntry (mat, invMat, detAccum, rank) [0..length mat-1]

With these changes, I now see a much lower memory usage: ~50s, 96MiB for the 600x600 matrix and ~128s, 167MiB for the 800x800 matrix.

There are likely opportunities for significant further optimization, if that turns out to be needed; e.g. I expect switching to a mutable array representation for your matrix would be another big boost.

Daniel Wagner
  • 145,880
  • 9
  • 220
  • 380
  • Thanks so much Daniel! I can't thank you enough for dedicating your time to assist me with this. I have incorporated all the changes you have pointed on. The code now runs in better time, i.e., 40 minutes. still not practical though. I I forgot to mention that my input matrices contain really small float numbers. that could be another factor that makes me code run slower on my machine. Thanks again! – Menish Jun 16 '23 at 09:21
  • 1
    @Menish Is that 40 minute figure for an 800x800 matrix? If so, how are you running the code? I don't see that behavior here when compiling (with or without optimizations), but I haven't tested the interpreted behavior, so if you're using ghci or runhaskell, you may be well-served by switching to using ghc proper. – Daniel Wagner Jun 16 '23 at 18:06
  • Hi Daniel. Again, thank you so much for your time. Indeed, 40 minute runtime for 800x800 matrix which is basically a matrix full of random floating numbers in range (-1,1). On another 800x800 matrix though, it runs for 5 minutes (which is great!). Basically there are 3 text files, 800x800, 800x800, and 1000x1000, each matrix has specific characteristics to test once the rank, once the determinant and once the inverse matrix. I am using ghc -O2 filename.hs to compile and then I run the executable. Any alternatives? I can also share the input matrices if needed. – Menish Jun 16 '23 at 19:29
  • 1
    @Menish Okay. Then at this point, it seems like the most likely-to-be-fruitful improvement is to use a better algorithm than Gaussian elimination. That's sort of the starting point, and people have thought long and hard (and successfully) about how to do better than that. – Daniel Wagner Jun 16 '23 at 21:02