Skip to content

gh-156955: Speed up csv.writer by avoiding per char searches - #156956

Open
brittanyrey wants to merge 1 commit into
python:mainfrom
brittanyrey:b-perf-csv-lineterminator
Open

gh-156955: Speed up csv.writer by avoiding per char searches#156956
brittanyrey wants to merge 1 commit into
python:mainfrom
brittanyrey:b-perf-csv-lineterminator

Conversation

@brittanyrey

@brittanyrey brittanyrey commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

In _csv, join_append_data() tested every character of every field for membership in dialect->lineterminator with PyUnicode_FindChar() (an out-of-line call made twice per character; the function runs a count pass and a copy pass). At 54% of cycles it was the single hottest symbol in csv.writer, which made writing CSV slower than parsing it back.

perf record on csv.writer, 2000 rows x 4 fields x 400 iterations, before:

54.12%  python    PyUnicode_FindChar
 2.02%  python    PyUnicode_FindChar@plt
25.57%  _csv..so  join_append
 3.12%  python    listiter_next

Cache the terminator's highest code point on the dialect, which is immutable, and compare inline. A character above that maximum cannot be in the terminator, so the scan is skipped. When it does run, it is a lightweight loop over the terminator rather than a boundary crossing call. PyUnicode_FindChar disappears from the profile entirely.

Results

Interleaved A/B, median of 25 per-round ratios, pinned to one CPU, with
csv.reader as an untouched control:

case main this PR
wide 500x2 (200-char fields) 3.159 ms 1.094 ms 2.88x
text 2000x4 1.486 ms 0.561 ms 2.64x
mixed 2000x4 1.430 ms 0.547 ms 2.62x
mixed QUOTE_ALL 1.438 ms 0.606 ms 2.37x
lineterminator='\n' 1.335 ms 0.605 ms 2.21x
lineterminator='END' 1.535 ms 0.822 ms 1.87x
quoted 2000x4 0.486 ms 0.323 ms 1.50x
short 5000x4 0.906 ms 0.650 ms 1.39x
csv.reader (control) 0.887 ms 0.887 ms 1.00x

Additional adversarial cases chosen to try and draw out regressions — nothing is below 1.00x:

case main this PR
3-char fields, lineterminator 4096 chars 13.949 ms 4.735 ms 2.94x
long non-ASCII fields 5.749 ms 2.126 ms 2.71x
1-char fields, lineterminator 4096 chars 7.438 ms 4.348 ms 1.71x
rows of empty fields, lineterminator='\r\n' 0.541 ms 0.543 ms 1.00x
rows of empty fields, lineterminator 4096 chars 4.200 ms 4.207 ms 1.00x

Relation to prior work

Reviewed comments and pulled in benchmarks from related PRs to harden this implementation.

Benchmark Code + Additional Performance Outputs

Benchmark code

Five blocks: the first three are this PR's, the last two are taken verbatim from
the related PRs so the results are directly comparable to theirs.

  1. This PR — interleaved A/B driver

Alternates the variants adjacent in time and reports the median of per-round
ratios rather than a ratio of global minima, because this machine drifts between
windows. Variants are separate _csv*.so builds swapped on PYTHONPATH against
one unchanged python, since comparing two python binaries in the same build
tree would load whichever shared module was built last.

  import json, os, statistics, subprocess, sys

  #  Edit for your checkout.
  PYTHON  = "./python"           # one unchanged interpreter, shared by all variants
  SO_ROOT = "./csvbench/so"      # SO_ROOT/<variant>/ holds that variant's _csv*.so
  BENCH   = "./csvbench/bench.py"
  CPU     = "8"                  # taskset target; set to None to skip pinning
  ROUNDS  = 25

  # base = main; simple = per-call maxchar (earlier revision); maxchar = this PR
  variants = sys.argv[1:] or ["base", "simple", "maxchar"]

  build_dir = os.path.dirname(os.path.abspath(PYTHON))
  cmd = (["taskset", "-c", CPU] if CPU else [])
  cmd += [os.path.abspath(PYTHON), os.path.abspath(BENCH)]

  acc = {v: {} for v in variants}
  for _ in range(ROUNDS):
      for v in variants:                      # interleaved: variants adjacent in time
          env = dict(os.environ, PYTHONPATH=os.path.abspath(os.path.join(SO_ROOT, v)))
          o = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=build_dir)
          for k, t in json.loads(o.stdout).items():
              acc[v].setdefault(k, []).append(t)

  base = variants[0]
  keys = list(acc[base])
  w = max(len(k) for k in keys) + 2
  hdr = f"{'case':<{w}}{base + ' (ms)':>13}"
  for v in variants[1:]:
      hdr += f"{v + ' (ms)':>13}{'speedup':>10}{'spread':>9}"
  print(hdr)
  for k in keys:
      line = f"{k:<{w}}{statistics.median(acc[base][k]) * 1e3:13.3f}"
      for v in variants[1:]:
          ratios = [b / c for b, c in zip(acc[base][k], acc[v][k])]   # per-round ratios
          line += (f"{statistics.median(acc[v][k]) * 1e3:13.3f}"
                   f"{statistics.median(ratios):9.2f}x"
                   f"{(max(ratios) - min(ratios)) * 100:8.1f}pp")
      print(line)
  1. This PR — workload definitions (bench.py)
  import csv, io, random, time, json

  def make(nrows, ncols, kind):
      random.seed(7)
      if kind == 'text':
          return [['some text value here', 'field%d' % i, 'abcdefghij', 'xyz'][:ncols] for i in range(nrows)]
      if kind == 'mixed':
          return [['field%d' % i, 'some text value here', str(i), '%.6f' % random.random()][:ncols] for i in range(nrows)]
      if kind == 'short':
          return [['a', 'b', 'c', 'd'][:ncols] for _ in range(nrows)]
      if kind == 'quoted':
          return [['a,b', 'c"d', 'e\r\nf', 'g'][:ncols] for _ in range(nrows)]
      if kind == 'wide':
          return [['x' * 200, 'y' * 200][:ncols] for _ in range(nrows)]
      raise ValueError(kind)
CASES = [
      ('mixed 2000x4',        dict(rows=make(2000,4,'mixed')),  {}),
      ('text 2000x4',         dict(rows=make(2000,4,'text')),   {}),
      ('short 5000x4',        dict(rows=make(5000,4,'short')),  {}),
      ('quoted 2000x4',       dict(rows=make(2000,4,'quoted')), {}),
      ('wide 500x2 (200ch)',  dict(rows=make(500,2,'wide')),    {}),
      ('mixed lineterm=\\n',  dict(rows=make(2000,4,'mixed')),  dict(lineterminator='\n')),
      ('mixed lineterm=END',  dict(rows=make(2000,4,'mixed')),  dict(lineterminator='END')),
      ('mixed QUOTE_ALL',     dict(rows=make(2000,4,'mixed')),  dict(quoting=csv.QUOTE_ALL)),
      ('reader (control)',    dict(rows=None),                  {}),
  ]

  def timeone(name, payload, kw, n):
      if name.startswith('reader'):
          data = io.StringIO()
          csv.writer(data).writerows(make(2000,4,'mixed'))
          s = data.getvalue()
          def run():
              list(csv.reader(io.StringIO(s)))
      else:
          rows = payload['rows']
          def run():
              b = io.StringIO(); csv.writer(b, **kw).writerows(rows); b.getvalue()
      run()
      best = 1e18
      for _ in range(n):
          t = time.perf_counter(); run(); e = time.perf_counter() - t
          if e < best: best = e
      return best

  out = {}
  for name, payload, kw in CASES:
      out[name] = timeone(name, payload, kw, 60)
  print(json.dumps(out))
  1. This PR — adversarial cases (adversarial.py)

Built specifically to look for inputs where the change loses.

  import csv, io, time, json

  def t(fn, n=40):
      fn(); best=1e18
      for _ in range(n):
          s=time.perf_counter(); fn(); e=time.perf_counter()-s
          if e<best: best=e
      return best

  def mk(rows, **kw):
      def f():
          b=io.StringIO(); csv.writer(b, **kw).writerows(rows); b.getvalue()
      return f

  out={}
  # pathological: long terminator, tiny/empty fields -> term_maxchar loop dominates
  empty  = [['']*20 for _ in range(2000)]
  one    = [['a']*20 for _ in range(2000)]
  short3 = [['abc']*20 for _ in range(2000)]
  for name, lt in [('lt=2', '\r\n'), ('lt=16', 'Z'*16), ('lt=256', 'Z'*256), ('lt=4096','Z'*4096)]:
      out[f'empty fields 2000x20 {name}']  = t(mk(empty,  lineterminator=lt))
      out[f'1-char fields 2000x20 {name}'] = t(mk(one,    lineterminator=lt))
      out[f'3-char fields 2000x20 {name}'] = t(mk(short3, lineterminator=lt))
  # "long non-ASCII lines", per the review on #138214
  nonascii = [['ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ'*8]*5 for _ in range(500)]
  out['non-ascii long fields'] = t(mk(nonascii))
  out['non-ascii long, lt=256'] = t(mk(nonascii, lineterminator='Z'*256))
  # terminator whose maxchar is high -> filter never rejects
  out['mixed, lt=emoji'] = t(mk([['field%d'%i,'some text value here','x'] for i in range(2000)],
                                lineterminator='\U0001F600'))
  print(json.dumps(out))
  1. From gh-138270: Use PyUnicodeWriter in csv.writer #138271 — csv.writer pyperf harness

Verbatim from #138271 (comment)

  import csv
  import io
  import pyperf

  runner = pyperf.Runner()

  INT_ROW = list(range(10))
  COMPLEX_STRING_ROW = ['a,b', 'c"d', 'e\nf'] * 3 + ['ghi']

  def write_the_rows(rows):
      f = io.StringIO()
      writer = csv.writer(f)
      writer.writerows(rows)
  for num_rows in (10, 1_000, 10_000, ):
      int_rows = [INT_ROW] * num_rows
      complex_rows = [COMPLEX_STRING_ROW] * num_rows

      runner.bench_func(
          f'writerows {num_rows} integer rows',
          write_the_rows,
          int_rows
      )

      runner.bench_func(
          f'writerows {num_rows} complex string rows',
          write_the_rows,
          complex_rows
      )
pyperf results — csv.writer harness from #138271

The harness #138271 used to show its own approach was 1.18x slower applied against this implementation.
Same script. main vs this branch.

benchmark main this PR
writerows 10 integer rows 11.1 us 10.4 us 1.07x faster
writerows 10 complex string rows 6.75 us 4.27 us 1.58x faster
writerows 1000 integer rows 1.00 ms 873 us 1.15x faster
writerows 1000 complex string rows 559 us 314 us 1.78x faster
writerows 10000 integer rows 10.0 ms 8.73 ms 1.15x faster
writerows 10000 complex string rows 5.52 ms 3.13 ms 1.76x faster

…er character

join_append_data() tested every character of every field for membership in
dialect->lineterminator with PyUnicode_FindChar(), an out-of-line call made
twice per character (the function runs a count pass and a copy pass). At
54% of cycles it was the single hottest symbol in csv.writer, which made
writing CSV slower than parsing it back.

Cache the terminator's highest code point on the dialect, which is
immutable, and compare inline. Ordinary text exceeds that maximum, so the
membership test is skipped without touching the terminator at all; when it
does run it is a short loop over the terminator's characters rather than a
cross-module call. Membership semantics are unchanged, including
multi-character, empty and non-BMP terminators.

Interleaved A/B, median of 25 per-round ratios, pinned to one CPU:

  mixed 2000x4          1.430 ms -> 0.547 ms   2.62x
  text 2000x4           1.486 ms -> 0.561 ms   2.64x
  wide 500x2 (200ch)    3.159 ms -> 1.094 ms   2.88x
  mixed QUOTE_ALL       1.438 ms -> 0.606 ms   2.37x
  mixed lineterm='\n'   1.335 ms -> 0.605 ms   2.21x
  mixed lineterm='END'  1.535 ms -> 0.822 ms   1.87x
  quoted 2000x4         0.486 ms -> 0.323 ms   1.50x
  short 5000x4          0.906 ms -> 0.650 ms   1.39x
  csv.reader (control)  0.887 ms -> 0.887 ms   1.00x

The maximum is cached on the dialect rather than recomputed per field so
that the change never loses. Degenerate inputs (rows of empty fields, or a
4096-character lineterminator) measure 1.00-1.01x, and a long terminator
with short fields improves from 0.11x to 2.94x against a per-field variant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant