Paralelisme data

Array.Parallel dan paralelisme data berbasis Task untuk memanfaatkan banyak inti.

Daftar isi

Paralelisme data membagi kumpulan data ke beberapa inti CPU agar pekerjaan penghitungan selesai lebih cepat, berbeda dengan asinkroni yang membebaskan thread saat menunggu I/O. Topik ini membahas Array.Parallel.map untuk elemen yang saling independen, Task.Run untuk membebankan pekerjaan CPU ke thread pool, serta Task.WhenAll untuk merakit hasil banyak tugas. Pembedaan asinkroni dan paralelisme ditutup dengan pola perakitan hasil yang menjaga keluaran setiap contoh tetap berurutan.

Asinkroni dan paralelisme: dua kebutuhan berbeda#

Asinkroni mengatasi waktu tunggu, sedangkan paralelisme data mengatasi waktu hitung. Pekerjaan I/O-bound (input/output bound) menghabiskan sebagian besar waktunya untuk menunggu jawaban dari luar proses, seperti jaringan atau diska, sehingga thread yang menunggu sebaiknya dibebaskan. Pekerjaan CPU-bound menghabiskan waktunya untuk menghitung, sehingga satu thread tidak menjadi lebih lambat karena menunggu dan cara mempercepatnya adalah menambah jumlah inti yang bekerja. Tabel berikut merangkum pembedaan tersebut beserta alat F# untuk masing-masing kebutuhan.

AspekAsinkroniParalelisme data
Karakter bebanI/O-bound, waktunya habis untuk menungguCPU-bound, waktunya habis untuk menghitung
Tujuanthread tetap bebas selama menungguwaktu hitung turun dengan lebih banyak inti
Alat F#async { }, task { }Array.Parallel, Task.Run
Merakit hasilAsync.ParallelTask.WhenAll

Mencampur keduanya bukan kesalahan teknis, tetapi masing-masing menyelesaikan persoalan yang berbeda. Menjalankan pekerjaan I/O melalui Task.Run memboroskan thread untuk sekadar menunggu, sedangkan menghitung beban berat di dalam workflow async tanpa alat paralel tetap menggunakan satu inti saja. Klasifikasi beban kerja, yaitu menunggu atau menghitung, diperiksa terlebih dahulu sebelum alatnya dipilih.

Array.Parallel.map#

Array.Parallel.map menerapkan satu fungsi pada setiap elemen array dengan membagi pekerjaannya ke beberapa inti, lalu mengembalikan array baru berisi hasilnya. Elemen dianggap independen karena fungsi peta tidak membutuhkan hubungan urutan antarelemen, dan array asalnya tidak berubah. Urutan hasil dijaga sama dengan urutan masukan walaupun perhitungannya berselang-seling di beberapa inti, sehingga penggunaan selanjutnya tidak perlu mengurutkan ulang. Berkas berikut mengonversi catatan suhu dari Celsius ke Fahrenheit pada enam elemen array.

fsharp konversi-suhu.fsx
// Konversi suhu per elemen array dengan paralelisme data.
let suhu = [| 21.5; 22.1; 20.8; 23.4; 19.9; 22.7 |]

let konversiFahrenheit (c: float) : float =
    c * 9.0 / 5.0 + 32.0

let fahrenheit = Array.Parallel.map konversiFahrenheit suhu

Array.iter2
    (fun c f -> printfn "%.1f C -> %.1f F" c f)
    suhu
    fahrenheit
text Keluaran
21.5 C -> 70.7 F
22.1 C -> 71.8 F
20.8 C -> 69.4 F
23.4 C -> 74.1 F
19.9 C -> 67.8 F
22.7 C -> 72.9 F

Perhitungan konversi di sini sengaja ringan, sehingga keuntungan paralelismenya tidak terasa pada enam elemen. Pada array yang lebih besar dengan fungsi per elemen yang lebih berat, misalnya menormalkan ribuan catatan sensor suhu, pembagian ke beberapa inti terasa pada waktu tempuhnya. Bentuk kodenya tetap sama, karena pemanggilan Array.Parallel.map tidak berbeda dengan Array.map biasa, sehingga perpindahan dari versi berurutan ke versi paralel hanya mengganti nama modulnya. Untuk array yang sangat kecil, versi paralel justru dapat lebih lambat karena pembagiannya sendiri menambah biaya, sehingga perpindahan modul sebaiknya disertai ukuran data yang masuk akal. Fungsi yang diberikan juga tidak boleh bergantung pada urutan proses, karena waktu berjalannya tiap elemen tidak dijamin.

Task.Run dan Task.WhenAll#

Task.Run membebankan satu fungsi ke thread pool dan mengembalikan Task yang dapat ditunggu, sehingga pekerjaan CPU tidak menahan pemanggilnya. Untuk memanfaatkan beberapa inti sekaligus, data dibagi menjadi beberapa potongan, masing-masing diproses pada tugas tersendiri, lalu Task.WhenAll menunggu semuanya dan mengembalikan array hasil yang urutannya mengikuti urutan tugas. Berkas berikut membagi penjumlahan kuadrat bilangan 1 sampai 1.000.000 menjadi empat potongan yang dihitung bersamaan.

fsharp rekap-cpu.fsx
// Empat potongan pekerjaan CPU dihitung pada tugas terpisah, lalu hasilnya dirakit.
open System.Threading.Tasks

let jumlahKuadrat (dari: int) (sampai: int) =
    Task.Run(fun () ->
        let mutable total = 0L
        for n in dari..sampai do
            total <- total + int64 n * int64 n
        (dari, sampai, total))

let potongan =
    [ (1, 250000); (250001, 500000); (500001, 750000); (750001, 1000000) ]

let tugas =
    potongan
    |> List.map (fun (d, s) -> jumlahKuadrat d s)
    |> Array.ofList

let hasil = (Task.WhenAll tugas).Result

for (d, s, total) in hasil do
    printfn "cuplikan %d..%d: %d" d s total

printfn "total: %d" (Array.sumBy (fun (_, _, t) -> t) hasil)
text Keluaran
cuplikan 1..250000: 5208364583375000
cuplikan 250001..500000: 36458427083375000
cuplikan 500001..750000: 98958489583375000
cuplikan 750001..1000000: 192708552083375000
total: 333333833333500000

Setiap potongan menghitung dengan variabel lokalnya sendiri, sehingga tidak ada state bersama yang perlu disinkronkan. Pembagian potongan berpengaruh pada kinerja, karena terlalu banyak potongan kecil menambah biaya penjadwalan, sedangkan potongan yang terlalu besar menyisakan inti yang menganggur. Aturan kasarnya adalah satu potongan per inti dengan ukuran kerja yang seimbang. Total pada baris terakhir dapat diperiksa dengan rumus penjumlahan kuadrat bilangan asli, yaitu n(n+1)(2n+1)6 \frac{n(n+1)(2n+1)}{6} untuk n=106 n = 10^6 . Apabila salah satu tugas melempar exception, kegagalan itu dilaporkan pada saat hasil Task.WhenAll ditunggu, sehingga tidak menghilang tanpa jejak.

Merakit hasil sebelum mencetak#

Setiap tugas paralel berjalan pada thread yang berbeda, sehingga pesan yang dicetak dari dalam pekerja tidak memiliki urutan yang dapat dijamin. Dua eksekusi skrip yang sama dapat menghasilkan susunan baris yang berbeda, dan keluaran seperti itu tidak dapat direproduksi untuk diperiksa. Pola yang dijaga pada modul ini adalah mengumpulkan hasil terlebih dahulu melalui Array.Parallel.map, Task.WhenAll, atau kanal jawaban, kemudian mencetaknya secara berurutan dari thread utama.

Dengan pola tersebut, paralelisme hanya berlangsung pada fase penghitungan, sedangkan fase pencetakan tetap linear dan hasilnya selalu sama pada setiap eksekusi. Ketika hasil perlu dijumlahkan atau dirangkum, agregasi dilakukan pada array yang sudah terkumpul, sehingga tidak ada state bersama yang diubah dari banyak thread sekaligus. Pola ini juga memudahkan pencarian penyebab, karena pesan diagnostik dicetak dari satu tempat setelah hasilnya lengkap.

Ringkasan#

  • Paralelisme data mempercepat pekerjaan CPU-bound dengan membagi elemen independen ke beberapa inti, sedangkan asinkroni menangani waktu tunggu pekerjaan I/O-bound.
  • Array.Parallel.map memproses elemen array pada banyak inti dengan hasil yang urutannya tetap sama, dan Task.Run membebankan pekerjaan CPU ke thread pool.
  • Task.WhenAll merakit hasil banyak tugas dalam urutan tugasnya, sehingga pencetakan dilakukan setelah perakitan agar keluaran tetap deterministik.

Referensi#