I’m trying to read a large csv file and want to it...
# general
d
I’m trying to read a large csv file and want to iterate over arrow record batches but daft is reading the whole file before starting to yield batches. Are there any options to process the csv file in chunks?
d
Curious to hear how large this csv file is and the average row size, and whether you're reading a local file or a file from an object store
d
Local file but had the same problem when pulling from cloud storage. It’s 42gb file
I’m using the native runner (I think that’s what it’s callled)
Still getting familiar with daft
5kb per row
c
could you provide a code snippet? There are several operations that force a materialization (such as
collect
). Wondering what the code is doing. AFAIK, something like
<http://df.to|df.to>_arrow_iter
should not force a materialization.
d
Not near my laptop atm but it was just read_csv with no options. And then iterating over record batches and printing the shape
👀 1
Copy code
import argparse
import csv
import time
from typing import Literal

import daft


def string_data() -> str:
   return "hello world, this is a test, with commas, and \"quotes\", and unicode: 測試"


def write_csv(name: str, data_type: Literal["string", "number"], num_rows: int = 1**6, num_cols: int = 100):
  with open(name, 'w', newline='') as csvfile:
      writer = csv.writer(csvfile)
      writer.writerow([f"col_{i}" for i in range(num_cols)])
      match data_type:
          case "string":
              data = string_data()
          case "number":
              data = 1.2345
      writer.writerows([data for _ in range(num_cols)] for _ in range(num_rows))


def process_csv(name: str):
  df = daft.read_csv(name)
  batches = df.to_arrow_iter()
  start = time.perf_counter()
  next(batches)
  end = time.perf_counter()
  print(f"Read first batch in {end - start:.2f} seconds")
  count = len(list(batches))
  print(f"Read remaining {count} batches in {time.perf_counter() - end:.2f} seconds")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", help="Available commands", required=True)

    write_parser = subparsers.add_parser("write", help="Write a large CSV file")
    write_parser.add_argument("--rows", type=int, default=1**6, help="Number of rows to generate")
    write_parser.add_argument("--cols", type=int, default=100, help="Number of columns to generate")
    write_parser.add_argument("--type", type=str, choices=["string", "number"], default="string", help="Type of data to generate")
    write_parser.add_argument("filename", type=str, help="The name of the file to write")

    process_parser = subparsers.add_parser("process", help="Process a CSV file")
    process_parser.add_argument("filename", type=str, help="The name of the file to process")

    args = parser.parse_args()

    if args.command == "write":
        write_csv(args.filename, data_type=args.type, num_rows=args.rows, num_cols=args.cols)
    elif args.command == "process":
        process_csv(args.filename)
I used this to generate some sample data then process it:
Copy code
DAFT_PROGRESS_BAR=false uv run daft_read_csv.py write --rows 1000000 --cols 100 --type number numbers.data.csv

DAFT_PROGRESS_BAR=false uv run daft_read_csv.py write --rows 1000000 --cols 10 --type string strings.data.csv

DAFT_PROGRESS_BAR=false uv run daft_read_csv.py process numbers.data.csv
Read first batch in 0.04 seconds
Read remaining 4679 batches in 1.44 seconds

DAFT_PROGRESS_BAR=false uv run daft_read_csv.py process strings.data.csv
Read first batch in 1.20 seconds
Read remaining 5780 batches in 0.24 seconds
The csv files were about the same size (669M for numbers, 764M for strings)
the first batch for string data takes a long time to yield
any idea why the first batch takes so long for string data?
c
It could be from the schema inference since your example has a lot of quotes and special characters. What happens if you pass in a schema to read_csv? is it still really slow for the first batch?
d
the first batch for string data takes a long time to yield
The nice thing to note is that even though the first batch had a higher latency, end-to-end processing time for strings and numbers are roughly the same.
since your example has a lot of quotes and special characters
I do think this is relevant. In Daft we use a technique called speculative parsing to process a csv file in parallel. It's "speculative" because you can't simply cut a csv file into even chunks then parse it, you need to adjust the chunks to contain complete records, so we "speculate" where the boundaries are then adjust accordingly. This gives us lower end-to-end processing times because we can read a single CSV file in parallel on multiple threads. But for an "adversarial" case with many quotes, the first batch may take longer as we need to figure out what the proper chunk boundary is. The good news is that once we figure out the first batch, we can start downstream processing as we figure out subsequent batches, which is why end-to-end latency is still good
d
With very large files this causes the process to use a lot of memory before yielding the first chunk. Is there a way to disable speculative parsing and process the file in order
d
I believe speculative parsing only happens for local files. Do you see the same behaviour when reading from object store?
With very large files this causes the process to use a lot of memory before yielding the first chunk
this shouldn't be an issue with speculative parsing either fwiw, since we process in 4MB chunks. The issue might lie elsewhere
actually, let me run some memory profilers and see what's going on
I dove a little deeper. You're not going to believe this, but what's happening is that your csv files are using \r\n and we didn't handle this case, so we indeed read the entire file first because speculative parsing was being thrown off. Writing a fix for this
🙌 1
Forgot to mention this, but the fix has been merged and is in the latest release of Daft (v0.6.5)