packages feed

wireform-parquet-0.1.0.0: scripts/parquet_selection_fixtures.py

#!/usr/bin/env python3
"""Generate shared PyArrow input and independent expected page-selection values.

Both benchmark harnesses parse the footer and page indexes on every iteration,
retain whole pages whose maximum meets the predicate, and materialize the same
rows. File IO and this fixture generation are outside the timed region.
"""

import argparse
from dataclasses import dataclass
from pathlib import Path

import pyarrow as pa
import pyarrow.parquet as pq

from parquet_selection_interop import Dataset, index_info

ROWS = 131072


@dataclass(frozen=True)
class Plan:
    name: str
    text: bool
    nullable: bool
    dictionary: bool
    codec: str
    rows: int = ROWS
    width: int = 0
    batch: int = 1024


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('directory', type=Path)
    parser.add_argument('--extended', action='store_true', help='also measure 4 KiB strings across many pages')
    args = parser.parse_args()
    root = args.directory
    root.mkdir(parents=True, exist_ok=True)
    cases = [
        Plan('int-required-plain', False, False, False, 'NONE'),
        Plan('int-required-dictionary', False, False, True, 'ZSTD'),
        Plan('int-nullable-plain', False, True, False, 'NONE'),
        Plan('int-nullable-dictionary', False, True, True, 'ZSTD'),
        Plan('text-required-plain', True, False, False, 'SNAPPY'),
        Plan('text-nullable-dictionary', True, True, True, 'ZSTD'),
    ]
    if args.extended:
        for nullable in (False, True):
            for dictionary in (False, True):
                name = f'text-{"nullable" if nullable else "required"}-{"dictionary" if dictionary else "plain"}-4k'
                cases.append(Plan(name, True, nullable, dictionary, 'ZSTD' if dictionary else 'NONE', rows=4096, width=4096, batch=16))
    plans = []
    for plan in cases:
        def value_at(i):
            if plan.width:
                prefix = f'long_{i//32:05d}_'
                return prefix + 'x' * (plan.width - len(prefix))
            if plan.text:
                return f'name_{i//32:05d}'
            return i//32 if plan.dictionary else i

        values = [None if plan.nullable and i % 17 == 0 else value_at(i) for i in range(plan.rows)]
        typ = pa.string() if plan.text else pa.int64()
        table = pa.Table.from_arrays([pa.array(values, typ)], schema=pa.schema([pa.field('x', typ, plan.nullable)]))
        path = root / f'{plan.name}.parquet'
        pq.write_table(table, path, compression=plan.codec, data_page_version='2.0',
                       use_dictionary=plan.dictionary, dictionary_pagesize_limit=2**20,
                       # Dictionary indices are much smaller than the wide
                       # strings; use a smaller target to retain several pages.
                       data_page_size=128 if plan.width and plan.dictionary else 4096,
                       write_batch_size=plan.batch, row_group_size=plan.rows,
                       write_page_index=True, write_page_checksum=False)
        pivot = value_at(plan.rows//2)
        decode = (lambda raw: raw.decode()) if plan.text else (lambda raw: int.from_bytes(raw, 'little', signed=True))
        dataset = Dataset(plan.name, typ, values, str(pivot), pivot, decode)
        _, _, locations, keep, encodings = index_info(path, dataset)
        assert any(keep) and not all(keep) and len(locations) > 1
        if plan.dictionary:
            assert any(encoding in (2, 8) for encoding in encodings)
        parts = []
        for index, ((_, _, start), retained) in enumerate(zip(locations, keep)):
            if retained:
                end = locations[index+1][2] if index+1 < len(locations) else plan.rows
                parts.append(table.slice(start, end-start))
        expected = pa.concat_tables(parts)
        pq.write_table(expected, root / f'{plan.name}-expected.parquet', compression='NONE', use_dictionary=False)
        plans.append(f'{plan.name} {"text" if plan.text else "int"} {pivot}')
        print(f'{plan.name}: {sum(keep)}/{len(keep)} pages, {expected.num_rows}/{plan.rows} rows')
    (root / 'selection-plan.txt').write_text('\n'.join(plans)+'\n')


if __name__ == '__main__':
    main()