10 億行資料集處理挑戰:從 15 分鐘到 5 秒

俞凡發表於2024-11-25
Golang 在處理 10 億行資料集的挑戰中展現了高效的併發處理和最佳化的 I/O 操作能力,透過使用 Parquet 二進位制格式,進一步提升了資料處理效能,並最終將處理時間從 15 分鐘最佳化到了 5 秒。原文: Go One Billion Row Challenge — From 15 Minutes to 5 Seconds

10 億行挑戰在程式設計界引起了廣泛關注,其主要目的是測試不同程式語言處理並彙總包含 10 億行的海量資料集的速度,而以效能和併發能力著稱的 Go 是有力競爭者。目前效能最好的 Java 實現處理資料的時間僅為 1.535 秒,我們看看 Go 的表現如何。

本文將基於 Go 特有的功能進行最佳化。注:所有基準資料都是在多次執行後計算得出的。

硬體設定

在配備 M2 Pro 晶片的 16 英寸 MacBook Pro 上進行了所有測試,該晶片擁有 12 個 CPU 核和 36 GB 記憶體。不同環境執行的測試結果可能因硬體而異,但相對效能差異應該差不多。

什麼是 "10 億行挑戰"?

挑戰很簡單:處理包含 10 億個任意溫度測量值的文字檔案,並計算每個站點的彙總統計資料(最小值、平均值和最大值)。問題在於如何高效處理如此龐大的資料集。

資料集透過程式碼倉庫中的 createMeasurements.go 指令碼生成。執行指令碼後,將得到一個 12.8 GB 大的由分號分隔的文字檔案(measurements.txt),包含兩列資料:站點名稱和測量值。

我們需要處理該檔案,並輸出以下結果:

{Station1=12.3/25.6/38.9, Station2=10.0/22.5/35.0, ...}

我們看看如何實現這一目標。

Go 初始實現

單核版本

我們從基本的單執行緒實現開始。該版本逐行讀取檔案,解析資料,並更新對映以跟蹤統計資料。

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

type Stats struct {
    Min   float64
    Max   float64
    Sum   float64
    Count int64
}

func processFile(filename string) {
    file, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    statsMap := make(map[string]*Stats)
    scanner := bufio.NewScanner(file)

    for scanner.Scan() {
        line := scanner.Text()
        parts := strings.Split(line, ";")
        if len(parts) != 2 {
            continue
        }

        station := parts[0]
        measurement, err := strconv.ParseFloat(parts[1], 64)
        if err != nil {
            continue
        }

        stat, exists := statsMap[station]
        if !exists {
            statsMap[station] = &Stats{
                Min:   measurement,
                Max:   measurement,
                Sum:   measurement,
                Count: 1,
            }
        } else {
            if measurement < stat.Min {
                stat.Min = measurement
            }
            if measurement > stat.Max {
                stat.Max = measurement
            }
            stat.Sum += measurement
            stat.Count++
        }
    }

    if err := scanner.Err(); err != nil {
        panic(err)
    }

    fmt.Print("{")
    for station, stat := range statsMap {
        mean := stat.Sum / float64(stat.Count)
        fmt.Printf("%s=%.1f/%.1f/%.1f, ", station, stat.Min, mean, stat.Max)
    }
    fmt.Print("\b\b} \n")
}

func main() {
    processFile("data/measurements.txt")
}
多核版本

為了充分利用多個 CPU 核,我們把檔案分成若干塊,然後利用 GoroutineChannel 並行處理。

package main

import (
    "bufio"
    "fmt"
    "os"
    "runtime"
    "strconv"
    "strings"
    "sync"
)

type Stats struct {
    Min   float64
    Max   float64
    Sum   float64
    Count int64
}

func worker(lines []string, wg *sync.WaitGroup, statsChan chan map[string]*Stats) {
    defer wg.Done()
    statsMap := make(map[string]*Stats)

    for _, line := range lines {
        parts := strings.Split(line, ";")
        if len(parts) != 2 {
            continue
        }

        station := parts[0]
        measurement, err := strconv.ParseFloat(parts[1], 64)
        if err != nil {
            continue
        }

        stat, exists := statsMap[station]
        if !exists {
            statsMap[station] = &Stats{
                Min:   measurement,
                Max:   measurement,
                Sum:   measurement,
                Count: 1,
            }
        } else {
            if measurement < stat.Min {
                stat.Min = measurement
            }
            if measurement > stat.Max {
                stat.Max = measurement
            }
            stat.Sum += measurement
            stat.Count++
        }
    }

    statsChan <- statsMap
}

func processFile(filename string) {
    file, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    numCPU := runtime.NumCPU()
    linesPerWorker := 1000000
    scanner := bufio.NewScanner(file)
    lines := make([]string, 0, linesPerWorker)

    var wg sync.WaitGroup
    statsChan := make(chan map[string]*Stats, numCPU)

    go func() {
        wg.Wait()
        close(statsChan)
    }()

    for scanner.Scan() {
        lines = append(lines, scanner.Text())
        if len(lines) >= linesPerWorker {
            wg.Add(1)
            go worker(lines, &wg, statsChan)
            lines = make([]string, 0, linesPerWorker)
        }
    }

    if len(lines) > 0 {
        wg.Add(1)
        go worker(lines, &wg, statsChan)
    }

    if err := scanner.Err(); err != nil {
        panic(err)
    }

    finalStats := make(map[string]*Stats)
    for partialStats := range statsChan {
        for station, stat := range partialStats {
            existingStat, exists := finalStats[station]
            if !exists {
                finalStats[station] = stat
            } else {
                if stat.Min < existingStat.Min {
                    existingStat.Min = stat.Min
                }
                if stat.Max > existingStat.Max {
                    existingStat.Max = stat.Max
                }
                existingStat.Sum += stat.Sum
                existingStat.Count += stat.Count
            }
        }
    }

    fmt.Print("{")
    for station, stat := range finalStats {
        mean := stat.Sum / float64(stat.Count)
        fmt.Printf("%s=%.1f/%.1f/%.1f, ", station, stat.Min, mean, stat.Max)
    }
    fmt.Print("\b\b} \n")
}

func main() {
    processFile("data/measurements.txt")
}
Go 實現結果

單核和多核版本的執行結果分別如下:

  • 單核版本:15 分 30 秒
  • 多核版本:6 分 45 秒

雖然多核版本有明顯改善,但處理資料仍然花了好幾分鐘。下面看看如何進一步最佳化。

利用 Go 的併發和緩衝 I/O 進行最佳化

為了提高效能,我們考慮利用緩衝 I/O,並最佳化 Goroutine。

package main

import (
    "bufio"
    "fmt"
    "os"
    "runtime"
    "strconv"
    "strings"
    "sync"
)

type Stats struct {
    Min   float64
    Max   float64
    Sum   float64
    Count int64
}

func worker(id int, jobs <-chan []string, results chan<- map[string]*Stats, wg *sync.WaitGroup) {
    defer wg.Done()
    for lines := range jobs {
        statsMap := make(map[string]*Stats)
        for _, line := range lines {
            parts := strings.Split(line, ";")
            if len(parts) != 2 {
                continue
            }

            station := parts[0]
            measurement, err := strconv.ParseFloat(parts[1], 64)
            if err != nil {
                continue
            }

            stat, exists := statsMap[station]
            if !exists {
                statsMap[station] = &Stats{
                    Min:   measurement,
                    Max:   measurement,
                    Sum:   measurement,
                    Count: 1,
                }
            } else {
                if measurement < stat.Min {
                    stat.Min = measurement
                }
                if measurement > stat.Max {
                    stat.Max = measurement
                }
                stat.Sum += measurement
                stat.Count++
            }
        }
        results <- statsMap
    }
}

func processFile(filename string) {
    file, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    numCPU := runtime.NumCPU()
    jobs := make(chan []string, numCPU)
    results := make(chan map[string]*Stats, numCPU)

    var wg sync.WaitGroup
    for i := 0; i < numCPU; i++ {
        wg.Add(1)
        go worker(i, jobs, results, &wg)
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    scanner := bufio.NewScanner(file)
    bufferSize := 1000000
    lines := make([]string, 0, bufferSize)

    for scanner.Scan() {
        lines = append(lines, scanner.Text())
        if len(lines) >= bufferSize {
            jobs <- lines
            lines = make([]string, 0, bufferSize)
        }
    }

    if len(lines) > 0 {
        jobs <- lines
    }
    close(jobs)

    if err := scanner.Err(); err != nil {
        panic(err)
    }

    finalStats := make(map[string]*Stats)
    for partialStats := range results {
        for station, stat := range partialStats {
            existingStat, exists := finalStats[station]
            if !exists {
                finalStats[station] = stat
            } else {
                if stat.Min < existingStat.Min {
                    existingStat.Min = stat.Min
                }
                if stat.Max > existingStat.Max {
                    existingStat.Max = stat.Max
                }
                existingStat.Sum += stat.Sum
                existingStat.Count += stat.Count
            }
        }
    }

    fmt.Print("{")
    for station, stat := range finalStats {
        mean := stat.Sum / float64(stat.Count)
        fmt.Printf("%s=%.1f/%.1f/%.1f, ", station, stat.Min, mean, stat.Max)
    }
    fmt.Print("\b\b} \n")
}

func main() {
    processFile("data/measurements.txt")
}
最佳化 Go 實現結果

最佳化後,處理時間大大縮短:

  • 多核最佳化版:3 分 50 秒

確實獲得了實質性改進,但仍然無法與最快的 Java 實現相媲美。

利用高效資料格式

由於文字檔案不是處理大型資料集的最有效方式,我們考慮將資料轉換為 Parquet 等二進位制格式來提高效率。

轉換為 Parquet

可以用 Apache Arrow 等工具將文字檔案轉換為 Parquet 檔案。為簡單起見,假設資料已轉換為 measurements.parquet

用 Go 處理 Parquet 檔案

我們用 parquet-go 庫來讀取 Parquet 檔案。

package main

import (
    "fmt"
    "log"
    "sort"

    "github.com/xitongsys/parquet-go/reader"
    "github.com/xitongsys/parquet-go/source/local"
)

type Measurement struct {
    StationName string  `parquet:"name=station_name, type=BYTE_ARRAY, convertedtype=UTF8, encoding=PLAIN_DICTIONARY"`
    Measurement float64 `parquet:"name=measurement, type=DOUBLE"`
}

type Stats struct {
    Min   float64
    Max   float64
    Sum   float64
    Count int64
}

func processParquetFile(filename string) {
    fr, err := local.NewLocalFileReader(filename)
    if err != nil {
        log.Fatal(err)
    }
    defer fr.Close()

    pr, err := reader.NewParquetReader(fr, new(Measurement), 4)
    if err != nil {
        log.Fatal(err)
    }
    defer pr.ReadStop()

    num := int(pr.GetNumRows())
    statsMap := make(map[string]*Stats)

    for i := 0; i < num; i += 1000000 {
        readNum := 1000000
        if i+readNum > num {
            readNum = num - i
        }

        measurements := make([]Measurement, readNum)
        if err = pr.Read(&measurements); err != nil {
            log.Fatal(err)
        }

        for _, m := range measurements {
            stat, exists := statsMap[m.StationName]
            if !exists {
                statsMap[m.StationName] = &Stats{
                    Min:   m.Measurement,
                    Max:   m.Measurement,
                    Sum:   m.Measurement,
                    Count: 1,
                }
            } else {
                if m.Measurement < stat.Min {
                    stat.Min = m.Measurement
                }
                if m.Measurement > stat.Max {
                    stat.Max = m.Measurement
                }
                stat.Sum += m.Measurement
                stat.Count++
            }
        }
    }

    stationNames := make([]string, 0, len(statsMap))
    for station := range statsMap {
        stationNames = append(stationNames, station)
    }
    sort.Strings(stationNames)

    fmt.Print("{")
    for _, station := range stationNames {
        stat := statsMap[station]
        mean := stat.Sum / float64(stat.Count)
        fmt.Printf("%s=%.1f/%.1f/%.1f, ", station, stat.Min, mean, stat.Max)
    }
    fmt.Print("\b\b} \n")
}

func main() {
    processParquetFile("data/measurements.parquet")
}
Parquet 處理結果

透過以 Parquet 格式處理資料,取得了顯著的效能提升:

  • Parquet 處理時間:5 秒

Go 的效能更進一步接近了最快的 Java 實現。

結論

Go 在 10 億行挑戰中表現出色。透過利用 Go 的併發模型和最佳化 I/O 操作,大大縮短了處理時間。透過將資料集轉換為二進位制格式(如 Parquet)可進一步提高效能。

主要收穫:

  • Go 高效的併發機制使其適合處理大型資料集。
  • 最佳化 I/O 和使用緩衝讀取可大幅提高效能。
  • 利用 Parquet 等高效資料格式可大大縮短處理時間。

最終想法

儘管 Go 可能無法取代速度最快的 Java 實現,但在高效處理大資料方面展示了令人印象深刻的能力。工具的選擇和最佳化可以縮小效能差距,使 Go 成為資料密集型任務的可行選擇。


你好,我是俞凡,在Motorola做過研發,現在在Mavenir做技術工作,對通訊、網路、後端架構、雲原生、DevOps、CICD、區塊鏈、AI等技術始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。為了方便大家以後能第一時間看到文章,請朋友們關注公眾號"DeepNoMind",並設個星標吧,如果能一鍵三連(轉發、點贊、在看),則能給我帶來更多的支援和動力,激勵我持續寫下去,和大家共同成長進步!

本文由mdnice多平臺釋出

相關文章