Skip to content

Optimize Dataset.subset() lookups - #686

Open
harshitgupta31415 wants to merge 5 commits into
jazzband:masterfrom
harshitgupta31415:optimize-dataset-subset
Open

harshitgupta31415 wants to merge 5 commits into
jazzband:masterfrom
harshitgupta31415:optimize-dataset-subset

Conversation

@harshitgupta31415

@harshitgupta31415 harshitgupta31415 commented Sep 20, 2026 •

Copy link
Copy Markdown

What changed?

Dataset.subset() was looking up the same rows and columns over and over. This keeps ordinary integer row selections in a set and caches column positions for ordinary, consistent datasets.

The set is only used for membership checks. Rows still follow the source dataset's order, and requested column order, duplicate columns and the first occurrence of duplicate source headers stay the same.

The latest follow-up avoids scanning every header just to check its type. Small datasets keep the original column-lookup loop; larger datasets stop the index scan once every needed first occurrence is found. This fixes the very wide dataset / first-column slowdown without dropping the full-table optimization.

I also kept the original behavior in the compatibility cases I tested:

  • Missing columns are still ignored during initial selection. Empty or all-missing column selections keep the existing behavior, including TypeError on a nonempty source.
  • If a selector removes an accepted header, a nonempty ordinary source keeps the original bare KeyError. If consuming the selector empties the source, it returns the same empty result as upstream.
  • Inconsistent row widths, including after public header-list edits, keep the original loop. Errors from reading an unselected short row are not silently skipped.
  • Dataset/Row subclasses, custom row containers or cell lists, a patched Row getter, and non-integer row selectors use the original column loop. If a custom source header is encountered before all needed positions are found, it also falls back without adding custom equality calls.

For an n × n ordinary dataset selecting all rows and columns, the expected work stays O(n²), down from upstream's O(n³). The initial column-selection membership checks are still present. Fallback paths retain the original column-lookup work and can be slower than the earlier PR; these checks are intentional compatibility overhead, not a promise that every input gets faster.

Extra storage is O(k + c) for k selected integer rows and c requested columns, in addition to the output. This does not double the whole dataset, but is not always negligible. An earlier isolated measurement on 64-bit CPython 3.14.5 found that a set of five million existing integer indexes occupied 128 MiB, with a 192 MiB construction peak. That excludes the existing index list, integer objects and dataset, and is interpreter-dependent.

The focused suite has 87 passing tests (83 compatibility tests and four existing subset tests), plus two passing subtests. The full local suite has 268 passing tests and two passing subtests, with the same timezone-dependent timestamp failure as upstream. Ruff and whitespace checks pass. Optional pandas paths and other Python versions were not exercised locally.

Fixes #685.

Fresh benchmarks — all 15 subset workloads, including slower cases

CPython 3.14.5 on Windows. Upstream base: a36c965. This targeted follow-up has 40 workloads in total: 15 subset and 25 filter. Each version gets five timing batches and five separate peak-allocation samples; the table shows medians.

The actual upstream, previous PR and updated method source snapshots run on identical inputs using the otherwise unchanged Dataset/Row implementation. Fixtures, validation and tracing are excluded from timing. One measurement runs at a time, allowed on a rotating pair of physical cores; the methods themselves are not parallelized.

Fixtures are synthetic, not collected personal data. Peak includes the live output but excludes preexisting inputs. It measures additional Python allocations during one call, not total dataset storage or RAM.

These are local observations, not speed guarantees. Thermal state, hybrid cores and OS activity add substantial timing noise, especially on tiny calls; near-1× ratios should not be treated as definite wins or losses.

r × c Selection Base time PR time Speed Base peak PR peak Memory
2 × 2 all 42.65 µs 49.80 µs 1.17× slower 2.30 KiB 2.46 KiB 1.07× more
20 × 8 all 235.51 µs 173.83 µs 1.35× faster 8.77 KiB 11.87 KiB 1.35× more
400 × 2 all 3.808 ms 2.268 ms 1.68× faster 81.25 KiB 110.80 KiB 1.36× more
400 × 400 all 3.063 s 62.628 ms 48.91× faster 1337.47 KiB 1419.15 KiB 1.06× more
4,000 × 50 all 676.387 ms 72.525 ms 9.33× faster 2259.69 KiB 2359.88 KiB 1.04× more
4,000 × 50 half rows, 3 columns 53.055 ms 6.448 ms 8.23× faster 412.78 KiB 525.70 KiB 1.27× more
20 × 8 duplicate columns 71.64 µs 57.79 µs 1.24× faster 8.02 KiB 10.50 KiB 1.31× more
200 × 8 integer headers 3.078 ms 2.560 ms 1.20× faster 53.19 KiB 59.82 KiB 1.12× more
400 × 50 no rows 40.569 ms 280.20 µs 144.79× faster 2.47 KiB 6.57 KiB 2.66× more
2 × 1,000 one row, column 0 6.62 µs 7.66 µs 1.16× slower 1.98 KiB 2.18 KiB 1.10× more
2 × 10,000 one row, column 0 7.25 µs 7.47 µs 1.03× slower 1.98 KiB 2.18 KiB 1.10× more
2 × 100,000 one row, column 0 37.60 µs 41.36 µs 1.10× slower 1.98 KiB 2.18 KiB 1.10× more
16 × 10,000 one row, column 0 33.59 µs 37.10 µs 1.10× slower 1.98 KiB 2.69 KiB 1.35× more
16 × 10,000 one row, column 9999 3.556 ms 711.39 µs 5.00× faster 2.02 KiB 2.72 KiB 1.35× more
1,000 × 50 one row, column 0 259.43 µs 144.36 µs 1.80× faster 2.05 KiB 2.69 KiB 1.31× more

Against the previous PR (8e98231), the 2 × 10,000 first-column selection went from 349.27 µs to 7.47 µs (upstream: 7.25 µs). The 1,000 × 50 first-column selection went from 59.91 µs to 144.36 µs because of row validation; upstream was 259.43 µs. The no-row selection also uses more temporary mapping storage than the previous PR. So this follow-up removes the wide-table regression, but is not faster than the previous PR in every case.

Follow-up update — 78ecabd

Added a follow-up fix for the edge cases. Small datasets and custom inputs keep the original column-lookup path, while normal larger datasets still use cached indexes. The header scan now stops once the needed columns are found, which fixes the wide-table slowdown. All 87 focused subset checks pass, and I’ve updated the benchmarks, including slower cases.

@hugovk

hugovk commented Sep 20, 2026

Copy link
Copy Markdown
Member

Please comment with a benchmark for this.

@codecov

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.87218% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.92%. Comparing base (a36c965) to head (78ecabd).

Files with missing lines Patch % Lines
tests/test_subset_compatibility.py 98.55% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #686      +/-   ##
==========================================
+ Coverage   93.48%   93.92%   +0.44%     
==========================================
  Files          29       30       +1     
  Lines        3315     3574     +259     
==========================================
+ Hits         3099     3357     +258     
- Misses        216      217       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hugovk hugovk added changelog: Added For new features changelog: Fixed For any bug fixes and removed changelog: Added For new features labels Sep 20, 2026
@harshitgupta31415

harshitgupta31415 commented Sep 20, 2026 •

Copy link
Copy Markdown
Author

Thanks, @hugovk! I benchmarked the base commit (a36c965) against this PR (98b9bd1) using Python 3.14.5 on Windows. Dataset construction was completed before timing, so the results measure only Dataset.subset().

Square datasets

Each result is the median of five runs.

Dataset size Base This PR Speedup
100×100 0.017916 s 0.002422 s 7.4×
200×200 0.116337 s 0.010250 s 11.3×
400×400 0.973558 s 0.024506 s 39.7×

Rectangular dataset

I also measured two rectangular datasets using the median of ten runs:

Dataset size Base This PR Speedup
4,000×50 0.235769 s 0.026538 s 8.9×
400×4,092 106.261264 s 0.344110 s 308.8×

For an n×n selection, the change reduces runtime from approximately O(n³) to O(n²). It uses a row set and a precomputed column-index list, but auxiliary space remains O(n); the O(n²) returned dataset still dominates total memory usage.

Benchmark code
import platform
from statistics import median
from timeit import repeat

from tablib import Dataset


print(f"Python {platform.python_version()}")

cases = (
    (100, 100, 5),
    (200, 200, 5),
    (400, 400, 5),
    (4_000, 50, 10),
    (400, 4_092, 10),
)

for row_count, column_count, repetitions in cases:
    headers = [f"column_{index}" for index in range(column_count)]
    row = tuple(range(column_count))
    dataset = Dataset(*(row for _ in range(row_count)), headers=headers)

    elapsed = median(
        repeat(
            lambda: dataset.subset(
                rows=range(row_count),
                cols=headers,
            ),
            repeat=repetitions,
            number=1,
        )
    )

    print(f"{row_count} rows x {column_count} columns: {elapsed:.6f}s")

PR benchmark

Base benchmark

Base benchmark

@hugovk

This comment was marked as resolved.

@harshitgupta31415

This comment was marked as resolved.

@hugovk

hugovk commented Sep 20, 2026

Copy link
Copy Markdown
Member

Can you check the behaviour change when cols=[] or every requested column is missing?

@harshitgupta31415

harshitgupta31415 commented Sep 20, 2026 •

Copy link
Copy Markdown
Author

Good catch, @hugovk this was changing the behavior. I’ve fixed it so both cols=[] and an all-missing column list behave the same as old again. Added tests for both cases too.

@harshitgupta31415

Copy link
Copy Markdown
Author

Hey @hugovk, just following up when you have a moment. I fixed the empty and all-missing column cases, then pushed 4357722 for another edge case: main accepts an unhashable row selector that compares equal to a row number. The PR now uses a set for plain integer row numbers and keeps the original list check for other values. For an n×n selection, the runtime is still O(n²).

The result still walks source rows in order, so repeated row numbers don't duplicate or reorder output. Requested columns keep their order and duplicates, missing names are ignored, and repeated source headers still resolve to their first match. On a nonempty dataset, no matching columns still raises the same TypeError as main. I added regression tests for the edge cases, and the current checks are green. The benchmark is above.

I left out the old else: raise KeyError because the earlier column filtering makes that branch unreachable for normal inputs. Checking again for every cell would bring back the repeated lookup. Could you review when you get a chance? Thanks!

@harshitgupta31415

Copy link
Copy Markdown
Author

Benchmark after the row-selection fix (4357722)

I reran the same benchmark on the updated PR with Python 3.14.5 on Windows. Dataset construction was outside the timed part. The base values below are from my earlier benchmark of a36c965; that commit has not changed. The updated PR values were measured now. Square cases use the median of five runs, and rectangular cases use the median of ten.

Dataset size Base (earlier run) Updated PR Speedup vs base
100×100 0.017916 s 0.001694 s 10.6×
200×200 0.116337 s 0.005802 s 20.1×
400×400 0.973558 s 0.022345 s 43.6×
4,000×50 0.235769 s 0.039248 s 6.0×
400×4,092 106.261264 s 0.825082 s 128.8×

The widest case varied across runs: a separate ten-run batch on the updated PR had a 0.487386 s median. So its exact speedup is approximate, but it remains much faster than base. The fast set lookup is still used for ordinary integer row selections.

@harshitgupta31415

Copy link
Copy Markdown
Author

Follow-up update (78ecabd):

Added a follow-up fix for the edge cases. Small datasets and custom inputs keep the original column-lookup path, while normal larger datasets still use cached indexes. The header scan now stops once the needed columns are found, which fixes the wide-table slowdown. All 87 focused subset checks pass, and I’ve updated the benchmarks, including slower cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: Fixed For any bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize Dataset.subset() to avoid repeated lookups

2 participants