C# ja miljardin rivin haaste, osa 1

Published on Wednesday, March 13, 2024

C# ja miljardin rivin haaste, osa 1

Tämän vuoden tammikuussa Gunnar Morling esitteli blogissaan haasteen, jossa on tarkoitus laskea tilastollisia arvoja miljardin rivin lämpötilasyötteestä. Morlingin alkuperäinen haaste oli tarkoitettu Java-ohjelmointikielen käyttäjille, mutta ajattelin kokeilla sitä C#:n kanssa.

Syöte

Syötteenä on siis tekstitiedosto, jossa on miljardi (1 000 000 000) riviä lämpötila-arvoja, jotka siis näyttävät seuraavanlaiselta

Hamburg;12.0
Bulawayo;8.9
Palembang;38.8
St. John's;15.2
Cracow;12.6
...

eli ensimmäisenä on mittauspaikan nimi (näiden nimissä mukana on myös ASCII-merkkialuuen ulkopuolelle kuuluvia merkkejä, kuten Ü), seuraavana puolipiste (;) ja viimeisenä pisteellä (.) eroteltu lämpötila desimaaliformaatissa, joka voi olla negatiivinen ja desimaaleja on aina tasan yksi.

Kannattaa huomata, etteivät mittaukset ole missään järjestyksessä, eikä jokaiselle mittauspaikalle ole yhtä paljon mittausarvoja.

Morlingin esimerkkitoteutus

Morling tarjosi kaikille lähtökohdaksi omaa Javalla toteutettua esimerkkitoteutusta, jonka tarjoaa pohjatuloksen, josta kuka tahansa voi lähteä parantamaan suorituskykyä. Tämä esimerkkitoteutus lukee siis kaikkien rivien arvot tiedostosta, ja laskee niiden perusteella jokaisen mittauspaikan pienimmän lämpötilan, keskiarvon ja suurimman arvon.

Kuten arvata saattaa, ei tämä esimerkkitoteutus ole suorituskyvyn kannalta paras mahdollinen, ja käytössä olleella testausraudalla kyseinen toteutus suorituu tehtävästä hieman alle 5 minuutissa. Haasteen voittanut koodi hoitaa saman laskennan noin 1,5 sekunnissa, eli optimointi on tässä tapauksessa todella tehokasta.

Ensimmäinen C#:lla toteutettu oma versio

Morlingin esimerkkototeutuksesta oli helppo tehdä suunnilleen vastaava C#-toteutus, kun Javan käyttämän Collector-toiminnallisuuden korvasi hieman yksinkertaisemmalla tavalla. Suorituskyvyn kannalta tämä toteutus on kaukana optimaalisesta, ja palaan tarkemmin näihin ongelmakohtiin seuraavassa blogikirjoituksessa.

Rivien lukeminen

Rivit voi lukea yksinkertaisesti seuraavalla tavalla

public record Measurement(string station, double value) 
{
    public Measurement(string[] parts) : this(parts[0], double.Parse(parts[1], CultureInfo.InvariantCulture))
    {

    }
}
var lines = File.ReadLines(args[0]);
foreach (var line in lines)
{
    measurements.Add(new Measurement(line.Split(';')));
}

Arvojen koostaminen mittauspaikan mukaan

Seuraavaksi mittaukset saa ryhmiteltyä jokaiselle aseman mukaisesti seuraavalla koodilla (hyödyntäen LINQ:ta)

IEnumerable<IGrouping<string, Measurement>> query = measurements.GroupBy(measurements => measurements.station).OrderBy(a => a.Key);

Laskenta ja tulostus

Halutut laskennat ja lopulta arvojen tulostaminen onnistuu seuraavalla tavalla

public record ResultRow(double min, double mean, double max) 
{
    public override string ToString() => round(min).ToString("F1", CultureInfo.InvariantCulture) + "/" + round(mean).ToString("F1", CultureInfo.InvariantCulture) + "/" + round(max).ToString("F1", CultureInfo.InvariantCulture);

    private static double round(double value) 
    {
        return Math.Round(value * 10.0) / 10.0;
    }
}
foreach (var group in query)
{
    Console.Write($"{group.Key}=");
    ResultRow resultRow = new ResultRow(group.Min(g => g.value), (Math.Round(group.Sum(g => g.value) * 10.0) / 10.0) / group.Count(), group.Max(g => g.value));
    Console.Write(resultRow);
    Console.Write(", ");
}

Varoitus

Yllä olevaa koodia ei kannata käyttää esimerkkinä annetun miljardin rivin syötetiedoston kanssa, jos kyseessä on tavallinen pöytäkone/kannettava, koska kaikkien rivien lukeminen kerralla keskusmuistiin vaatinee yli 100 gigatavua muistia.

🖥️