Tutorial

โหลด CSV ใน Python ยังไงให้เร็วสปาดปรู๊ด ๆ

By Arnon Puitrakul - 19 November 2020 - 2 min read min(s)

โหลด CSV ใน Python ยังไงให้เร็วสปาดปรู๊ด ๆ

การโหลดข้อมูลเข้า น่าจะเป็นขั้นตอนแรกในการทำงานกับข้อมูลเลยก็ว่าได้ ถ้าเราทำงานกับข้อมูลเล็ก ๆ มันไม่น่ามีปัญหาเท่าไหร่ แต่เมื่อเราทำกับข้อมูลที่โคตรใหญ่ขึ้น ปัญหามันตามมามากมาย แต่เรื่องเวลา ทำให้เราปวดหัวมาเป็นเดือนเลยทีเดียว โดยเฉพาะการโหลด CSV เข้ามาสำหรับการทำ Deep Learning โดยใช้ Keras วันนี้เราจะมาลองเปรียบเทียบวิธีที่เราใช้งานกัน

ปัญหาที่เจอ

เราต้องการที่จะเขียน Generator สำหรับการโหลดไฟล์ CSV สำหรับ Train ตัว Neural Network โดยใช้ Keras ซึ่งหลัก ๆ วิธีที่เรารู้ ณ วันนี้คือ มี 2 วิธีคือ การเขียน Generator และ การ Implement Class Sequence จาก Keras โดยตรง ซึ่งไว้เราจะมาเล่าอีกที เมื่อเราแก้ปัญหาจากมันได้แล้ว

สิ่งที่เจอคือ ข้อมูลมันโหลดได้ช้ามาก ๆ จน GPU ว่างงานไปเลย ซึ่งก็ งง มาก เพราะ Disk ที่เราใช้เก็บ Data เป็น NVMe เรื่องของความเร็วในการอ่าน และ เขียน ไม่น่าจะมีปัญหาเลยจริง ๆ ไม่ว่าจะเป็นทั้ง Random และ Sequential Read/Write เลย แต่มันก็ยังช้าอยู่ดี จนไปดู Disk Read Speed ขึ้นมาหลัก GB/sec เลย ซึ่งน่าแปลกมา เพราะเราโหลดไม่กี่ Record ต่อ Chunk เท่านั้น ไม่ถึง 250 Record

ทำให้สงสัยมากว่า มันเกิดอะไรขึ้นกัน เลย เป็นที่มาของ Blog นี้นั่นเอง วันนี้เราจะมาลอง Bennchmark หาวิธีที่เราว่าน่าจะเร็วที่สุดในการอ่าน CSV File ขนาดใหญ่กัน

Experiment 1 : Pandas Skiprows

import pandas as pd

for index in range(0,no_of_batch) :
	chunk_data = pd.read_csv(csv_file_name, nrows=batch_size, skiprows=batch_size*index)
    

วิธีแรกที่เราลองคือ เราใช้ Pandas ตามปกติ แต่เราไม่สามารถอ่านทั้งไฟล์รวดเดียวได้ เพราะไฟล์หลาย 100 GB มาก ๆ เราเลยคิดง่าย ๆ ด้วยการใช้ nrows ในการกำหนดจำนวน Row ที่จะอ่านเข้ามา และ ใช้ skiprows เพื่อให้มันเริ่มอ่านจาก Row ที่เราต้องการ เพื่อให้ได้ช่วงของ Row ที่เราต้องการจริง ๆ เราเลยใช้ Index ในการกำกับลงไป เราก็จะได้ใน Row ที่เราต้องการลงมา

วิธีนี้ น่าจะเป็นวิธีที่ง่ายที่สุดในการที่เราจะอ่าน CSV File ในตำแหน่งที่เราต้องการได้ โดยที่เรายังสามารถกำหนด Index ในการอ่านได้อีก ทำให้เราสามารถสั่งให้เรากระจายงานเป็น Parallel ในการทำ Data Preprocessing ได้

Experiment 2 : Pandas Chunk

import pandas as pd

for chunk in pd.read_csv(csv_file_name, chunksize=batch_size) :
	// Do Sth

เราเจอปัญหาจากวิธีแรก เราเห็นว่าเมื่อ Index มันไปไกลขึ้นเรื่อย ๆ รู้สึกว่ามันช้าขึ้นเรื่อย ๆ จนเราไปอ่านใน Document ของ Pandas เราเห็นว่ามันมี Argument ตัวนึงคือ chunksize ที่ทำให้ Pandas มันจะไม่โหลดข้อมูลทั้งหมดออกมาเลย แต่มันจะให้ออกมาเป็น Generator เพื่อให้เราค่อย ๆ เรียก Data ได้ออกมาทีละ Chunk เรื่อย ๆ

แต่ปัญหาคือ เราไม่สามารถที่จะทำให้มันทำงานแบบ Parallel ได้ เพราะ Generator มันค่อย ๆ โยนข้อมูลออกมาให้เราทีละ Chunk ไม่สามารถเอาไปใช้กับ Sequence ใน Keras ได้ ก็เป็นเศร้าไป แต่ก็ถือว่าเร็วกว่า วิธีแรกแน่ ๆ

Experiment 3: Built-in CSV Reader

import csv

csv_file = open(csv_file_name, 'r')
csv_reader = csv.reader(csv_file)

for row in csv_reader :
	//Do Sth

csv_file.close()

หลังจากเราได้วิธีที่ 2 มา เรามาเจอว่าเออ จริง ๆ แล้วใน Python เอง แบบที่เราไม่ได้ลง Library อะไรเลย มันก็มี Built-in Function สำหรับการอ่าน CSV File มาให้เราเลย วิธีการเขียนจริง ๆ ก็แทบไม่ได้ต่างจากการเขียนในแบบที่ 2 ที่มันให้กลับมาเป็น Generator แต่อันนี้ มันให้กลับมาทีละ Row ตรง ๆ แต่เราต้องการเอามันออกมาเป็น Batch เราเลยเขียนใหม่เป็น

import csv

csv_file = open(csv_file_name, 'r')
csv_reader = csv.reader(csv_file)
row_container = []

for row in csv_reader :
	row_container.append(row)
    
    if len(row) == batch_size :
    	// Data Preprocessing
        yield X,Y
        
        row_container = []
csv_file.close()

จริง ๆ ก็ไม่ได้มีอะไรมาก แต่เราเขียนเพิ่ม เพื่อให้มันคืนค่า เมื่อมันรันไปตาม Row เรื่อย ๆ จนถึงที่เรากำหนดไว้ให้มันคืนค่ากลับมา ซึ่งแน่นอนว่า วิธีนี้เหมือนกับวิธีที่ 2 ที่เราลองไป หลักการอะไรเหมือนกันหมด แค่เราใช้ Built-in Function ของ Python เอง ไม่ต้องมาลง Pandas

มาลอง Benchmark กัน

สำหรับวิธีแรกเดาได้ไม่ยากเลยว่า มันน่าจะเป็นวิธีที่ช้าที่สุด แล้วอีก 2 วิธีที่เหลือละ อันไหนจะเร็วกว่ากัน ในการทดลองนี้ เราใช้ข้อมูลขนาด 1000 ล้าน Record และ ใช้ Chunk Size คือ 20,000 บน แต่เราจะไม่ให้โหลดทั้งหมด มันจะนานเกินไป เราเอามาแค่ 100 Chunks เท่านั้นหรือก็คือ 200,000 Record แรก โดยใช้เครื่อง iMac 27-inch 2019 ที่ CTO CPU เป็น Intel Core i9 กับข้อมูลทั้งหมดอยู่บน NVMe SSD ที่เสียบผ่าน Thunderbolt Enclosure

กราฟด้านบนนี้ เราแบ่ง Experiment ออกเป็น 3 ตัวใหญ่ ๆ อย่างที่เราเล่าไปก่อนหน้านี้ โดยที่แกน X เป็นวิธีที่เราใช้ และ แกน Y เป็นเวลาที่เครื่องโหลด Data ทั้งหมด 100 Chunk โดยเราจะเห็นว่า การใช้ Skiprows ใน Pandas เป็นวิธีที่ช้าที่สุดอย่างที่คาดไว้ แต่อันที่เราคิดผิดจริง ๆ คือ Built-in CSV Reader กลับทำงานได้ช้ากว่า CSV Reader ของ Pandas เอง

กราฟนี้เราได้เพราะเราสงสัยว่า skiprows มันทำงานยังไง ในกราฟแกน X คือ Chunk Number และ แกน Y คือ เวลาที่ใช้ในการโหลด Chunk นั้น ๆ เราจะเห็นได้ว่า ตัวที่ใช้ Pandas skiprow เส้นสีน้ำเงิน ยิ่งเรา skip row มากเท่าไหร่ เรายิ่งใช้เวลาในการโหลดมากขึ้นเท่านั้น ซึ่งผิดกับอีก 2 วิธีที่เหลือ ที่ไม่ว่าเราจะโหลดผ่านไปกี่ Chunk ก็ตาม เวลาในการโหลดแต่ละ Chunk ก็แทบไม่เพิ่มขึ้นเลย

แต่ถ้าเอาเรื่องความเร็วตอนนี้ก่อน การใช้ Pandas และกำหนด chunksize เป็นวิธีที่ทำให้เราสามารถโหลด CSV File ขนาดใหญ่ได้เร็วที่สุดใน 3 วิธีนี้แล้ว แต่เราไม่ได้บอกนะว่า วิธีนี้เร็วที่สุด แต่เร็วที่สุดใน 3 วิธีที่เราทดลองแล้ว

ทำไม Skiprows ถึงเป็นแบบนั้นละ

เป็นคำถามที่น่าสนใจมากกว่า ทำไมการใช้ Skiprows ใน Pandas ถึงทำให้มันแสดงพฤติกรรมแบบนั้นออกมา เอาหล่ะ เรามาลองคิดกันว่า ถ้าเราจะโหลด CSV สักไฟล์ออกมา เราน่าจะต้องอ่านไฟล์มาทีละบรรทัดเหมือนกับ Code ด้านล่างนี้

csv_file = open(csv_file_path, 'r')
line = csv_file.readline()

while line != '' :
	line_components = line[:-1].split(',')
    
    // Do Sth
    
	line = csv_file.readline()
csv_file.close()

มันก็ดูเป็นชุดของ Code ที่สำหรับอ่านไฟล์ และ Substring ตาม Comma ที่เป็น Format ของ CSV อยู่แล้ว ถามว่า ถ้าเราต้องการที่จะอ่าน Record ที่ 10 เลย เราจะทำได้ยังไงบ้าง

วิธีแรกที่เราคิดออกคือ เวลาเราอ่านไฟล์ มันจะมีสิ่งที่เรียกว่า Cursor เหมือนกับเวลาเราใช้งานพวกโปรแกรม Word Processing มันก็จะเป็นจุดที่เราสามารถเลื่อนไปมา เพื่อเพิ่ม หรือแก้ไขข้อความได้ การอ่านก็เช่นกัน เวลามันอ่าน มันก็จะอ่านโดยอ้างอิงตำแหน่งจาก Cursor นั่นแหละ ทำให้ถ้าเราเลื่อนตำแหน่งของ Cursor ไปที่ Record นั้น ๆ ได้ เราก็สามารถอ่าน Record นั้น ๆ ได้อย่างง่ายดายเลย

ความยากของการเลื่อน Cursor ไม่ใช่อยู่ที่การเลื่อน แต่เป็นตำแหน่งของ Cursor ต่างหากที่ยาก เพราะเราไม่มี Index มาก่อนเลย และ CSV จริง ๆ แล้วสิ่งที่มันแน่นอนคือ จำนวน Column ไม่ใช่ขนาดของแต่ละ Row นั่นทำให้การเลื่อน Cursor ไปที่ Record ตรง ๆ เลยทำไม่ได้ตรง ๆ

ทำให้วิธีเดียวที่เราจะทำได้คือ การอ่านไปทีละบรรทัด เช่นเราบอกว่าเราต้องการอ่าน Record ที่ 1000 แปลว่า เราต้องอ่าน 999 Record ทิ้งไป ทำให้ Cursor มันเลื่อนไปที่จุดเริ่มต้นของ Record ที่ 1000 Pandas ถึงจะเริ่มอ่านได้ มันต้องทำแบบนี้ไปเรื่อย ๆ จนทำให้เราเจอ Graph แบบที่เราแสดงให้ดูเมื่อครู่นั่นเอง

สรุป

จากการทดลองวันนี้ทำให้เราเห็นว่า การโหลด CSV โดยใช้การกำหนด chunksize ใน Pandas เป็นวิธีที่เร็วที่สุดใน 3 วิธีที่เราเอามาลองในวันนี้ แต่เราต้องย้ำอีกทีว่า เราไม่ได้บอกว่าวิธีนี้เร็วที่สุดในการอ่าน แต่มันเร็วที่สุดใน 3 วิธีที่เราบอกมาแล้ว แต่ด้วยวิธีการกำหนด chunksize ไม่ได้ทำให้เราอ่านและ Preprocess Data ได้แบบ Parallel เศร้าไป ถ้าใครมีวิธีที่ดีกว่าลองบอกมาหน่อย เรากำลังหาวิธีดี ๆ อยู่เหมือนกันฮ่า ๆ