Skip to content

Commit 7986b42

Browse files
jbrockmendelclaude
andauthored
PERF: build read_sas string columns as pyarrow arrays directly (GH#47339) (pandas-dev#66464)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 116b5f5 commit 7986b42

6 files changed

Lines changed: 657 additions & 10 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ Performance improvements
228228
- Performance improvement in :func:`read_sas` for RLE- and RDC-compressed SAS7BDAT files (:issue:`47339`)
229229
- Performance improvement in :func:`read_sas` for SAS7BDAT files by pre-computing date/datetime column classification once during metadata parsing instead of per chunk (:issue:`47339`)
230230
- Performance improvement in :func:`read_sas` for SAS7BDAT files with full-precision (8-byte) numeric columns, with up to ~2x speedup on bulk reads (:issue:`47339`)
231+
- Performance improvement in :func:`read_sas` for SAS7BDAT files with string columns when an ``encoding`` is given, with up to ~7x speedup on bulk reads (:issue:`47339`)
231232
- Performance improvement in :func:`read_sas` for compressed SAS7BDAT files by reusing the decompression buffer instead of allocating per row (:issue:`47339`)
232233
- Performance improvement in :func:`read_sas` for compressed SAS7BDAT files with many rows per page, up to ~1.7x faster (:issue:`47339`)
233234
- Performance improvement in :func:`read_sas` when decoding strings (:issue:`47339`)

pandas/_libs/sas.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import numpy as np
2+
13
from pandas.io.sas.sas7bdat import SAS7BDATReader
24

35
class Parser:
46
def __init__(self, parser: SAS7BDATReader) -> None: ...
57
def read(self, nrows: int) -> None: ...
8+
def string_values(self) -> list[np.ndarray]: ...
69

710
def collect_page_subheaders(parser: SAS7BDATReader) -> int: ...

pandas/_libs/sas.pyx

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ from libc.stdint cimport (
1313
from libc.stdlib cimport (
1414
calloc,
1515
free,
16+
realloc,
1617
)
1718
from libc.string cimport (
1819
memcpy,
@@ -91,6 +92,41 @@ cdef buf_free(Buffer buf):
9192
if buf.data != NULL:
9293
free(buf.data)
9394

95+
96+
cdef struct StrColumn:
97+
# Growable utf-8 value buffer backing one string column.
98+
#
99+
# Grown by doubling rather than preallocated to the declared column width
100+
# times nrows: SAS pads character fields out to a fixed width, so the
101+
# worst case badly overstates how many bytes the data actually needs.
102+
uint8_t *data
103+
int64_t size
104+
int64_t capacity
105+
106+
107+
cdef bint strcol_reserve(StrColumn *col, int64_t extra) except 0:
108+
"""Ensure `col` has room for `extra` more bytes, growing by doubling."""
109+
cdef:
110+
int64_t needed = col.size + extra
111+
int64_t capacity = col.capacity
112+
uint8_t *data
113+
114+
# A zero capacity means data is still NULL, so allocate even when nothing is
115+
# needed: an empty first cell would otherwise leave callers memcpy-ing into
116+
# NULL, which is undefined behavior even for a length of zero.
117+
if needed <= capacity and capacity > 0:
118+
return True
119+
if capacity == 0:
120+
capacity = 4096
121+
while capacity < needed:
122+
capacity *= 2
123+
data = <uint8_t *>realloc(col.data, capacity)
124+
if data is NULL:
125+
raise MemoryError(f"Failed to allocate {capacity} bytes")
126+
col.data = data
127+
col.capacity = capacity
128+
return True
129+
94130
# rle_decompress decompresses data using a Run Length Encoding
95131
# algorithm. It is partially documented here:
96132
#
@@ -269,6 +305,12 @@ cdef:
269305
int page_data_type = const.page_data_type
270306
int subheader_pointers_offset = const.subheader_pointers_offset
271307

308+
# See sas_constants.py for what each string mode means. string_mode_table
309+
# needs no alias here: it is whatever the other two are not.
310+
int string_mode_object = const.string_mode_object
311+
int string_mode_utf8 = const.string_mode_utf8
312+
uint8_t undefined_byte = const.undefined_byte
313+
272314
# Copy of subheader_signature_to_index that allows for much faster lookups.
273315
# Lookups are done in lookup_subheader_index_32/64. The C structures are
274316
# initialized in _init_subheader_signatures().
@@ -482,6 +524,24 @@ cdef class Parser:
482524
int64_t[:] column_types
483525
uint8_t[:, :] byte_chunk
484526
object[:, :] string_chunk
527+
# Output for the pyarrow string fast path (string_mode_utf8/_table):
528+
# str_cols[js] accumulates column js's utf-8 bytes, str_offsets[js, r+1]
529+
# records the end of row r within it, and str_valid[js, r] is 0 for a
530+
# null cell. Unused when str_mode is string_mode_object.
531+
int str_mode
532+
StrColumn *str_cols
533+
int n_str_cols
534+
int64_t[:, ::1] str_offsets
535+
uint8_t[:, ::1] str_valid
536+
# str_table[b * str_table_width : ...] holds the utf-8 for source byte
537+
# b, str_table_len[b] its length (undefined_byte if b is not a valid
538+
# character in str_encoding). Only set for string_mode_table. const
539+
# because these are shared, read-only arrays cached across readers.
540+
const uint8_t[::1] str_table
541+
const uint8_t[::1] str_table_len
542+
int str_table_width
543+
bint str_ascii_identity
544+
object str_encoding
485545
# (offset, length) of each compressed data subheader on the current
486546
# page, filled in by collect_page_subheaders and indexed by
487547
# current_row_on_page_index in readline().
@@ -510,6 +570,8 @@ cdef class Parser:
510570
def __cinit__(self, object parser):
511571
self.decompress = NULL
512572
self.decompressed_buf = Buffer(NULL, 0)
573+
self.str_cols = NULL
574+
self.n_str_cols = 0
513575

514576
@cython.wraparound(False)
515577
@cython.boundscheck(False)
@@ -526,6 +588,22 @@ cdef class Parser:
526588
self.offsets = parser.column_data_offsets()
527589
self.byte_chunk = parser._byte_chunk
528590
self.string_chunk = parser._string_chunk
591+
# sas7bdat.py always supplies these, using empty arrays for whichever
592+
# of the two string outputs is inactive, so the memoryview assignments
593+
# below never see None.
594+
self.str_mode = parser._str_mode
595+
self.str_offsets = parser._str_offsets
596+
self.str_valid = parser._str_valid
597+
self.str_table = parser._str_table
598+
self.str_table_len = parser._str_table_len
599+
self.str_table_width = parser._str_table_width
600+
self.str_ascii_identity = parser._str_ascii_identity
601+
self.str_encoding = parser._str_encoding
602+
if self.str_mode != string_mode_object:
603+
self.n_str_cols = self.str_offsets.shape[0]
604+
self.str_cols = <StrColumn *>calloc(self.n_str_cols, sizeof(StrColumn))
605+
if self.str_cols is NULL:
606+
raise MemoryError("Failed to allocate string column buffers")
529607
self.data_subheader_offsets = parser._data_subheader_offsets
530608
self.data_subheader_lengths = parser._data_subheader_lengths
531609
self.row_length = parser.row_length
@@ -566,7 +644,34 @@ cdef class Parser:
566644
self.current_row_on_page_index = parser._current_row_on_page_index
567645

568646
def __dealloc__(self):
647+
cdef Py_ssize_t js
569648
buf_free(self.decompressed_buf)
649+
if self.str_cols is not NULL:
650+
for js in range(self.n_str_cols):
651+
free(self.str_cols[js].data)
652+
free(self.str_cols)
653+
654+
def string_values(self):
655+
"""
656+
Return one exact-size uint8 array of utf-8 bytes per string column.
657+
658+
Copies out of the growable parse buffers so the returned arrays do not
659+
pin their (up to 2x) spare capacity for the lifetime of the result.
660+
"""
661+
cdef:
662+
Py_ssize_t js
663+
StrColumn *col
664+
uint8_t[::1] out
665+
666+
result = []
667+
for js in range(self.n_str_cols):
668+
col = &self.str_cols[js]
669+
values = np.empty(col.size, dtype=np.uint8)
670+
if col.size > 0:
671+
out = values
672+
memcpy(&out[0], col.data, col.size)
673+
result.append(values)
674+
return result
570675

571676
def read(self, int nrows):
572677
cdef:
@@ -719,13 +824,22 @@ cdef class Parser:
719824
cdef:
720825
Py_ssize_t j
721826
int s, k, m, jb, js, current_row, rpos
827+
int bi, blen, table_width
722828
int64_t lngt, start, ct
829+
uint8_t bval
830+
bint ascii_identity, mode_object, mode_utf8
723831
Buffer source
832+
StrColumn *col
724833
int64_t[:] column_types
725834
int64_t[:] lengths
726835
int64_t[:] offsets
727836
uint8_t[:, :] byte_chunk
728837
object[:, :] string_chunk
838+
StrColumn *str_cols
839+
int64_t[:, ::1] str_offsets
840+
uint8_t[:, ::1] str_valid
841+
const uint8_t[::1] str_table
842+
const uint8_t[::1] str_table_len
729843
bint compressed
730844

731845
assert offset + length <= self.cached_page_len, "Out of bounds read"
@@ -747,6 +861,17 @@ cdef class Parser:
747861
offsets = self.offsets
748862
byte_chunk = self.byte_chunk
749863
string_chunk = self.string_chunk
864+
# Resolved once per row so that the per-cell branches below test a
865+
# local rather than reloading the module-level mode constants.
866+
mode_object = self.str_mode == string_mode_object
867+
mode_utf8 = self.str_mode == string_mode_utf8
868+
str_cols = self.str_cols
869+
str_offsets = self.str_offsets
870+
str_valid = self.str_valid
871+
str_table = self.str_table
872+
str_table_len = self.str_table_len
873+
table_width = self.str_table_width
874+
ascii_identity = self.str_ascii_identity
750875
s = 8 * self.current_row_in_chunk_index
751876
js = 0
752877
jb = 0
@@ -777,10 +902,57 @@ cdef class Parser:
777902
# .rstrip(b"\x00 ") but without Python call overhead.
778903
while lngt > 0 and buf_get(source, start + lngt - 1) in b"\x00 ":
779904
lngt -= 1
780-
if lngt == 0 and self.blank_missing:
781-
string_chunk[js, current_row] = np_nan
905+
if mode_object:
906+
if lngt == 0 and self.blank_missing:
907+
string_chunk[js, current_row] = np_nan
908+
else:
909+
string_chunk[js, current_row] = buf_as_bytes(
910+
source, start, lngt
911+
)
782912
else:
783-
string_chunk[js, current_row] = buf_as_bytes(source, start, lngt)
913+
col = &str_cols[js]
914+
if lngt > 0 or not self.blank_missing:
915+
# Spell out the bound the raw-pointer reads below rely
916+
# on; the rstrip loop's buf_get established it.
917+
assert start + lngt <= <int64_t>source.length, \
918+
"Out of bounds read"
919+
# Cells start null (str_valid is zero-initialized), so
920+
# only non-null ones need marking.
921+
str_valid[js, current_row] = 1
922+
strcol_reserve(col, lngt * table_width)
923+
if mode_utf8:
924+
memcpy(col.data + col.size, &source.data[start], lngt)
925+
col.size += lngt
926+
else:
927+
k = 0
928+
if ascii_identity:
929+
# Bytes below 0x80 are their own utf-8 in this
930+
# encoding, so an all-ascii cell (the common
931+
# case) costs one memcpy instead of a byte loop.
932+
while k < lngt and source.data[start + k] < 0x80:
933+
k += 1
934+
memcpy(col.data + col.size, &source.data[start], k)
935+
col.size += k
936+
while k < lngt:
937+
bval = source.data[start + k]
938+
blen = str_table_len[bval]
939+
if blen == undefined_byte:
940+
# Re-decode the cell so the error matches
941+
# the UnicodeDecodeError that the per-cell
942+
# strict decode used to raise.
943+
buf_as_bytes(source, start, lngt).decode(
944+
self.str_encoding
945+
)
946+
raise AssertionError(
947+
"decoding an undefined byte should have raised"
948+
)
949+
for bi in range(blen):
950+
col.data[col.size + bi] = (
951+
str_table[bval * table_width + bi]
952+
)
953+
col.size += blen
954+
k += 1
955+
str_offsets[js, current_row + 1] = col.size
784956
js += 1
785957

786958
self.current_row_on_page_index += 1

0 commit comments

Comments
 (0)