Skip to content

GH-43660: [C++] Add a CastingGenerator to Parquet Reader that applies required casts before slicing#43661

Closed
sahil1105 wants to merge 21 commits into
apache:mainfrom
bodo-ai:sahil/pq-read-add-casting-gen
Closed

GH-43660: [C++] Add a CastingGenerator to Parquet Reader that applies required casts before slicing#43661
sahil1105 wants to merge 21 commits into
apache:mainfrom
bodo-ai:sahil/pq-read-add-casting-gen

Conversation

@sahil1105

@sahil1105 sahil1105 commented Aug 12, 2024

Copy link
Copy Markdown
Contributor

Rationale for this change

(See #43660)

What changes are included in this PR?

  • In ParquetFileFormat::ScanBatchesAsync, set the batch_size for the reader to INT64_MAX. Since we already have a SlicingGenerator that is responsible for converting the reader's output into batch_size sized batches, we don't need the reader to batch its output.
  • Add a CastingGenerator that applies any required casts to the output of the reader before the output is passed to SlicingGenerator. The logic for the cast closely follows the logic in MakeExecBatch (
    Result<ExecBatch> MakeExecBatch(const Schema& full_schema, const Datum& partial,
    ). There's an existing note in that function which states that the readers should be the ones performing the cast (
    // This *should* be handled by readers, and will just be an error in the future.
    ), so I believe this change gets us closer to that goal.

Are these changes tested?

I would like some feedback on the best way to test this. The changes passed our internal test cases, however, it may not account for all possible use cases.

Are there any user-facing changes?

No.

@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #43660 has been automatically assigned in GitHub to PR creator.

@sahil1105 sahil1105 changed the title GH-43660 [C++] Add a CastingGenerator to Parquet Reader that applies required casts before slicing GH-43660: [C++] Add a CastingGenerator to Parquet Reader that applies required casts before slicing Aug 12, 2024
@sahil1105 sahil1105 force-pushed the sahil/pq-read-add-casting-gen branch from 226d2d1 to f1c0d6e Compare August 12, 2024 22:59
@@ -555,6 +562,57 @@ Future<std::shared_ptr<parquet::arrow::FileReader>> ParquetFileFormat::GetReader
});
}

struct CastingGenerator {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit curious why casting generator is required, since during reading parquet to arrow, Parquet reader already applies a round of casting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right and it likely does perform some casts during the read. However, it doesn't seem to be doing it for certain cases like String to LargeString.
Could you point me to where the Parquet Reader should be performing this cast? I'm happy to add it there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!
Based on what I see, that is only responsible for casting the data to the logical type specified in the parquet metadata and not the Arrow type we want to convert to (the one in the dataset_schema). For strings, that seems to always map to a String type (based on FromByteArray which is called by GetArrowType which is called by GetTypeForNode which is called by NodeToSchemaField which is called in SchemaManifest::Make during the creation of the LeafReader). Am I missing something?

@mapleFU mapleFU Aug 13, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on what I see, that is only responsible for casting the data to the logical type specified in the parquet metadata and not the Arrow type we want to convert to (the one in the dataset_schema)

Parquet logical type doesn't have an arrow schema, isn't it? Binary reader reads from ::arrow::BinaryBuilder, and casting it to user-specified binary type.

For strings, that seems to always map to a String type (based on FromByteArray which is called by GetArrowType which is called by GetTypeForNode which is called by NodeToSchemaField which is called in SchemaManifest::Make during the creation of the LeafReader).

Yeah, you're right, the read "cast" with file-schema rather than an expected schema. I think a native cast is better here but this doesn't solve your problem, perhaps I can trying to add a naive SchemaManifest with hint solving here, but it would spend some time.

::arrow::Result<std::shared_ptr<ArrowType>> GetTypeForNode(
    int column_index, const schema::PrimitiveNode& primitive_node,
    SchemaTreeContext* ctx)

Maybe we should rethink the GetTypeForNode handling for string/large_string/stringView, or using some handle written type hint here. A casting generator is also good for me when the reader cannot provide the right casting

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provide hint would looks like: apache/arrow-rs#5939

Maybe I can add separate issue for that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parquet logical type doesn't have an arrow schema, isn't it?

As far as I understand, the parquet metadata may or may not have the arrow schema. I believe it depends on the writer. It looks like it tries to get that using GetOriginSchema in SchemaManifest::Make. However, the schema at write time might not be the same as the schema the reader expects.

Binary reader reads from ::arrow::BinaryBuilder, and casting it to user-specified binary type.

Sorry, I didn't quite follow. Are you saying that we should use this to do the cast at read time somehow?

I think a native cast is better here but this doesn't solve your problem, perhaps I can trying to add a naive SchemaManifest with hint solving here, but it would spend some time.
Maybe we should rethink the GetTypeForNode handling for string/large_string/stringView, or using some handle written type hint here.

That makes sense to me.

Maybe I can add separate issue for that

That would be great, thanks!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just check this. We can first add a CastingGenerator, because sometimes we would have schema evolution here, and need cast to a "final type". The Parquet reader code can be regard as an optimization

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, thanks!

@github-actions github-actions Bot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 13, 2024
@sahil1105 sahil1105 force-pushed the sahil/pq-read-add-casting-gen branch from bdac54a to e832a0d Compare August 13, 2024 13:50
@sahil1105

Copy link
Copy Markdown
Contributor Author

I'm not sure why the "Java JNI / AMD64 manylinux2014 Java JNI (pull_request)" and "continuous-integration/appveyor/pr" are failing. The latter in particular passed on the previous commit which is the same as this commit (my last commit was an empty commit to trigger the CI again). Any insights would be appreciated.

I would also appreciate feedback on if and what unit tests need to be added for this change. It seems like the existing unit tests provide good coverage of this code (they helped me find some of the bugs), but I'm happy to add more if it is deemed useful.

@mapleFU

mapleFU commented Aug 15, 2024

Copy link
Copy Markdown
Member

The interface general LGTM. I'm a little busy on working days, and will take a careful around in weekend. You also can mark it as ready for review here

@sahil1105

Copy link
Copy Markdown
Contributor Author

The interface general LGTM. I'm a little busy on working days, and will take a careful around in weekend. You also can mark it as ready for review here

Thanks, I appreciate it!

@sahil1105 sahil1105 marked this pull request as ready for review August 15, 2024 12:20
@mapleFU mapleFU self-requested a review August 15, 2024 12:36
@sahil1105

Copy link
Copy Markdown
Contributor Author

@mapleFU If you get a chance to review this PR this weekend, that'd be great. Thanks!

@mapleFU

mapleFU commented Aug 24, 2024

Copy link
Copy Markdown
Member

Sorry for delaying I'll take a pass today.

I'm out today so I didn't finish review, will continue after I wake up

@mapleFU mapleFU left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea looks great to me, but I don't know would setting memory to maximum matters

Comment thread cpp/src/arrow/dataset/file_parquet.cc Outdated
if (this->cols_to_skip_.count(field->name())) {
// Maintain the original input type.
out_schema_fields.emplace_back(field->WithType(column->type()));
out_cols.emplace_back(std::move(column));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So cols_to_skip_ is just not "Project" this field, rather than not need this field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, to skip the cast and leave them as they are.

@@ -617,6 +684,9 @@ Result<RecordBatchGenerator> ParquetFileFormat::ScanBatchesAsync(
[this, options, parquet_fragment, pre_filtered,
row_groups](const std::shared_ptr<parquet::arrow::FileReader>& reader) mutable
-> Result<RecordBatchGenerator> {
// Since we already do the batching through the SlicingGenerator, we don't need the
// reader to batch its output.
reader->set_batch_size(std::numeric_limits<int64_t>::max());
// Ensure that parquet_fragment has FileMetaData

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming a large file, would memory usage grows high in this case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have that problem regardless of the batch size of the reader.
We pass this reader to reader->GetRecordBatchGenerator (a few lines down). This eventually creates a RowGroupGenerator (

RowGroupGenerator(::arrow::internal::checked_pointer_cast<FileReaderImpl>(reader),
). If we look at the implementation of FetchNext for RowGroupGenerator, it essentially calls ReadOneRowGroup which reads the entire row group into a table and then creates an output stream using TableBatchReader (
table_reader.set_chunksize(batch_size);
). So, we're already reading an entire row group into a single table. The batch_size just creates a reader on top of it which generates zero-copy slices. This is the same as what the SlicingGenerator does and is redundant. In my opinion, it's better to set the batch size to INT_MAX so that it returns one table per row group, and then we can perform the batching through the SlicingGenerator.

Comment on lines +710 to +712
// We need to skip casting the dictionary columns since the dataset_schema doesn't
// have the dictionary-encoding information. Parquet reader will return them with the
// dictionary type, which is what we eventually want.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've forget something here, would Dict(String) and Cast(xxx -> LargeString) matters?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure, so I left that case untouched. There are more casts done further up the chain (e.g. in MakeExecBatch potentially) that seem to handle those cases. I couldn't figure out how the dictionary case gets handled either, so I left it as is. I'm happy to implement it here if you could point me in the right direction.

@sahil1105 sahil1105 force-pushed the sahil/pq-read-add-casting-gen branch from 912db12 to f1a4955 Compare August 29, 2024 18:22
@mapleFU

mapleFU commented Sep 2, 2024

Copy link
Copy Markdown
Member

Also cc @bkietz as the expert here

@sahil1105

Copy link
Copy Markdown
Contributor Author

cc @srilman

@srilman

srilman commented Apr 30, 2025

Copy link
Copy Markdown
Contributor

@scott-routledge2

scott-routledge2 commented Sep 24, 2025

Copy link
Copy Markdown
Contributor

cc @bkietz , @pitrou

Hello! I was interested in picking up this PR from Sahil since the use case still seems relevant to us. I tested locally with the latest changes on main and it looked like this PR can improve performance of the Scanner quite a bit, although there were some concerns about the robustness of setting the batch size to INT_MAX in ScanBatchesAsync.

Was wondering if I could get some a review/feedback on whether this is headed in the right direction. Thanks!


BENCHMARK(ParquetScanToTableCastStrings);

} // namespace dataset

@scott-routledge2 scott-routledge2 Sep 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local results (comparing to main):

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Non-regressions: (20)
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
                                                                 benchmark           baseline          contender  change %                                                                                                                                                                                                                                counters
            ParquetScanToTableCastStrings/num_batches:1000/batch_size:1000   1.068M items/sec  10.574M items/sec   890.391                               {'family_index': 0, 'per_family_instance_index': 2, 'run_name': 'ParquetScanToTableCastStrings/num_batches:1000/batch_size:1000', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 1}
             ParquetScanToTableCastStrings/num_batches:1000/batch_size:100 761.915K items/sec   1.065M items/sec    39.760                                {'family_index': 0, 'per_family_instance_index': 1, 'run_name': 'ParquetScanToTableCastStrings/num_batches:1000/batch_size:100', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 4}
              ParquetScanToTableCastStrings/num_batches:1000/batch_size:10  86.631K items/sec 107.530K items/sec    24.124                                 {'family_index': 0, 'per_family_instance_index': 0, 'run_name': 'ParquetScanToTableCastStrings/num_batches:1000/batch_size:10', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 5}
       ScanOnlyBench/num_batches:1000/batch_size:1000/scan_alg:0/real_time     41.041 MiB/sec     49.586 MiB/sec    20.820                          {'family_index': 1, 'per_family_instance_index': 4, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:1000/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 6}
         ScanOnlyBench/num_batches:1000/batch_size:10/scan_alg:0/real_time    457.346 KiB/sec    518.384 KiB/sec    13.346                            {'family_index': 1, 'per_family_instance_index': 0, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:10/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 6}
        ScanOnlyBench/num_batches:1000/batch_size:100/scan_alg:0/real_time      4.421 MiB/sec      5.011 MiB/sec    13.339                           {'family_index': 1, 'per_family_instance_index': 2, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:100/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 6}
         ScanOnlyBench/num_batches:1000/batch_size:10/scan_alg:1/real_time    634.336 KiB/sec    718.653 KiB/sec    13.292                            {'family_index': 1, 'per_family_instance_index': 1, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:10/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
 MinimalEndToEndBench/num_batches:1000/batch_size:100/scan_alg:0/real_time      4.563 MiB/sec      5.089 MiB/sec    11.535                    {'family_index': 0, 'per_family_instance_index': 2, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:100/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 7}
  MinimalEndToEndBench/num_batches:1000/batch_size:10/scan_alg:1/real_time    624.277 KiB/sec    680.752 KiB/sec     9.047                     {'family_index': 0, 'per_family_instance_index': 1, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:10/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
  MinimalEndToEndBench/num_batches:1000/batch_size:10/scan_alg:0/real_time    483.179 KiB/sec    522.102 KiB/sec     8.055                     {'family_index': 0, 'per_family_instance_index': 0, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:10/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 7}
        ScanOnlyBench/num_batches:1000/batch_size:100/scan_alg:1/real_time      6.578 MiB/sec      7.080 MiB/sec     7.645                           {'family_index': 1, 'per_family_instance_index': 3, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:100/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
MinimalEndToEndBench/num_batches:1000/batch_size:1000/scan_alg:0/real_time     46.819 MiB/sec     49.794 MiB/sec     6.353                   {'family_index': 0, 'per_family_instance_index': 4, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:1000/scan_alg:0/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 7}
       ScanOnlyBench/num_batches:1000/batch_size:1000/scan_alg:1/real_time     67.173 MiB/sec     70.629 MiB/sec     5.145                          {'family_index': 1, 'per_family_instance_index': 5, 'run_name': 'ScanOnlyBench/num_batches:1000/batch_size:1000/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
                                          GetFilteredFragments/single_file   1.501M items/sec   1.557M items/sec     3.709  {'family_index': 3, 'per_family_instance_index': 0, 'run_name': 'GetFilteredFragments/single_file', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 105, 'num_filtered_fragments': 1.0, 'num_fragments': 10000.0}
 MinimalEndToEndBench/num_batches:1000/batch_size:100/scan_alg:1/real_time      6.327 MiB/sec      6.481 MiB/sec     2.430                    {'family_index': 0, 'per_family_instance_index': 3, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:100/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
                                                GetFilteredFragments/range 229.364K items/sec 232.693K items/sec     1.451      {'family_index': 4, 'per_family_instance_index': 0, 'run_name': 'GetFilteredFragments/range', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 16, 'num_filtered_fragments': 9800.0, 'num_fragments': 10000.0}
MinimalEndToEndBench/num_batches:1000/batch_size:1000/scan_alg:1/real_time     61.721 MiB/sec     62.435 MiB/sec     1.156                   {'family_index': 0, 'per_family_instance_index': 5, 'run_name': 'MinimalEndToEndBench/num_batches:1000/batch_size:1000/scan_alg:1/real_time', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 9}
                                           GetFilteredFragments/single_dir   3.419M items/sec   3.444M items/sec     0.733 {'family_index': 1, 'per_family_instance_index': 0, 'run_name': 'GetFilteredFragments/single_dir', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 236, 'num_filtered_fragments': 100.0, 'num_fragments': 10000.0}
                                            GetFilteredFragments/multi_dir  40.060K items/sec  40.215K items/sec     0.386    {'family_index': 2, 'per_family_instance_index': 0, 'run_name': 'GetFilteredFragments/multi_dir', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 3, 'num_filtered_fragments': 100.0, 'num_fragments': 10000.0}
                                                           GetAllFragments  35.682M items/sec  35.433M items/sec    -0.698                                                 {'family_index': 0, 'per_family_instance_index': 0, 'run_name': 'GetAllFragments', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 2282, 'num_fragments': 10000.0}


@zeroshade zeroshade requested review from mapleFU and pitrou October 21, 2025 16:14
-> Result<RecordBatchGenerator> {
// Since we already do the batching through the SlicingGenerator, we don't need the
// reader to batch its output.
reader->set_batch_size(std::numeric_limits<int64_t>::max());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, if you have a very large row group (say 1 GB or more), what this will do is read the entire file at once, then slice it, instead of generating batches progressively.

So this sounds like a bad idea to me. The entire purpose of ScanBatchesAsync is to generate batches progressively so that the consumer can process them without waiting for the entire file to be read. This allows covering IO latencies and also decreasing memory footprint (say you're computing the average of a column: it would be wasteful to load the entire column at once instead of having a running average computation over smaller batches).

@scott-routledge2 scott-routledge2 Oct 27, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my understanding of this example, wouldn't calling ScanBatchesAsync create a RowGroupGenerator, which would then call ReadOneRowGroup and read the entire row group into an in-memory table anyways? The batch size here seems to just control the chunk size of that table.

The goal of this PR was to reduce the overhead of casting batches of string offset buffers (which would require O(N^2) memory/compute where N is the length of a row group) by performing the cast on the entire table/row-group at once, which we will have at some point because of ReadOneRowGroup, so I don't think this represents a regression.

However, I understand wanting to keep the intended semantics of ScanBatchesAsync the same. Maybe instead we can perform the cast at a different layer of the stack e.g. at the reader level?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation @scott-routledge2 .


static void ParquetScanBenchmark_Customize(benchmark::internal::Benchmark* b) {
for (const int32_t num_batches : {1000}) {
for (const int batch_size : {10, 100, 1000}) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those batch sizes are much too small for good performance, so I'm not sure why they're being benchmarked.
You could instead try {1000, 10000, 100000}.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, I updated the benchmark with those batch sizes:

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Non-regressions: (3)
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
                                                     benchmark           baseline          contender  change %                                                                                                                                                                                                    counters
ParquetScanToTableCastStrings/num_batches:1000/batch_size:1000 186.666K items/sec  11.289M items/sec  5947.814   {'family_index': 0, 'per_family_instance_index': 0, 'run_name': 'ParquetScanToTableCastStrings/num_batches:1000/batch_size:1000', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 1}
ParquetScanToTableCastStrings/num_batches:10/batch_size:100000 403.236M items/sec 797.722M items/sec    97.830 {'family_index': 0, 'per_family_instance_index': 2, 'run_name': 'ParquetScanToTableCastStrings/num_batches:10/batch_size:100000', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 100}
ParquetScanToTableCastStrings/num_batches:100/batch_size:10000  57.155M items/sec 110.185M items/sec    92.784  {'family_index': 0, 'per_family_instance_index': 1, 'run_name': 'ParquetScanToTableCastStrings/num_batches:100/batch_size:10000', 'repetitions': 1, 'repetition_index': 0, 'threads': 1, 'iterations': 39}

Comment on lines +726 to +727
// Casting before slicing is more efficient. Casts on slices might require wasteful
// allocations and computation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain which kind of wasteful allocations and computations would be required when casting a slice?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When casting the string offsets buffer, we allocate offset + length + 1 elements, memset the first offset elements to zero, and perform the cast on length many elements. So assuming batch_size = 1000, a row group of 10,000 elements would require allocating buffers of lengths 1001, 2001, ... 10,001 to perform the casts on all batches.

ctx->Allocate((output->length + output->offset + 1) * sizeof(output_offset_type)));

@pitrou

pitrou commented Nov 10, 2025

Copy link
Copy Markdown
Member

Ok, I think this is approaching the problem from the wrong end.

  1. We should fix the issue in CastBinaryToBinaryOffsets so that the O(N^2) behavior cannot occur anymore
  2. Also, we are now able to read Parquet directly as LargeString, so the intermediate cast is not required anymore

@scott-routledge2

scott-routledge2 commented Nov 11, 2025

Copy link
Copy Markdown
Contributor

Ok, I think this is approaching the problem from the wrong end.

1. We should fix the issue in `CastBinaryToBinaryOffsets` so that the O(N^2) behavior cannot occur anymore

2. Also, we are now able to read Parquet [directly as LargeString](https://github.com/apache/arrow/pull/46532), so the intermediate cast is not required anymore

I would be happy to open a separate issue to look at CastBinaryToBinaryOffsets, however, I wasn't sure how to make this cast more efficient without changing the API significantly? A single cast of a slice is O(offset + length), since a new buffer for the string offsets needs to be created with the same shape as the original slice. Casting slices of a size N array only becomes O(N^2) when length of the slices << N, so to me, the most straightforward approach would be to change how the casting is being invoked i.e. casting the entire array at once vs slices.

For the second point, the cast here was not a workaround for reading LargeString data, but rather, we were working with datasets that have a single, unified schema and individual files that potentially have different schemas (e.g. string vs large_string ), so the casting would still be necessary at some point to reconcile the differences in schema. This cast is currently handled in MakeExecBatch, but there is comment that this should be done by the reader, so I believe this PR would be an incremental step in achieving this goal, but was wondering what your thoughts would be?

@pitrou

pitrou commented Nov 12, 2025

Copy link
Copy Markdown
Member

I would be happy to open a separate issue to look at CastBinaryToBinaryOffsets, however, I wasn't sure how to make this cast more efficient without changing the API significantly? A single cast of a slice is O(offset + length), since a new buffer for the string offsets needs to be created with the same shape as the original slice.

No API needs to be changed here. The output of casting is by definition the same logical length as the input, but it does not need to have the same physical allocation shape (i.e. offset).

The current code reuses the input offset (through ZeroCopyCastExec) mostly because it's easier. But it should be simple as well to produce the output with a different offset (not necessarily 0, because we would like to reuse the null bitmap and that implies we must keep offset % 8 the same).

@zanmato1984 zanmato1984 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for chiming in late. As I’m going through the code, I had two quick questions:
1. When is the cast applied before this change?
2. Could you clarify how the O(N^2) complexity arises? I read the issue description but couldn’t quite follow it - probably because I’m not very familiar with the dataset code - so a short walkthrough would be really helpful.

Thanks.

Updated: just saw the other comment about the cast:

This cast is currently handled in MakeExecBatch, but there is comment that this should be done by the reader, so I believe this PR would be an incremental step in achieving this goal, but was wondering what your thoughts would be?

@scott-routledge2

scott-routledge2 commented Nov 18, 2025

Copy link
Copy Markdown
Contributor

Could you clarify how the O(N^2) complexity arises? I read the issue description but couldn’t quite follow it - probably because I’m not very familiar with the dataset code - so a short walkthrough would be really helpful.

@zanmato1984 of course! It's not so much an issue with datasets as it is the actual casting kernel in compute as @pitrou pointed out.

When casting small slices of size L of a larger array of length N, to cast the first slice, we have to allocate a new offsets buffer for the up/downcasted int offsets that has size L+1. The second slice will have offset L, and because of the way casting is implemented, we will actually need to allocate a buffer of size 2L+1 and zero out the first L elements since the output of the cast inherits the offset from the input slice. Continuing this pattern, the amount of work required is roughly, L + 2L + 3L + ... N ~ N^2/2L or O(N^2) if L is constant.

Where this relates to datasets is when reading a parquet file, ScanAsyncBatches essentially is reading one row group at a time, and then produces slices on top that row group, which then need to be casted in MakeExecBatch, following the pattern described above.

@zanmato1984

zanmato1984 commented Nov 18, 2025

Copy link
Copy Markdown
Contributor

Thanks for the explanation @scott-routledge2 ! I now see the problem and sorry for you to have to illustrate that again.

IIUC there are two aspects in this particular use case:

  1. The cast implementation of utf8 to large_utf8 for a slice with non-zero offset exhibits a performance penalty of over-allocation and redundant zero-filling.
  2. The cast is implicitly relying on the thin MakeExecBatch doing a reluctant schema aligning.

I think they are separated. And for 1, I agree with @pitrou that it is an arguable implementation choice we made for BinaryToBinaryCastExec. In other words, quote:

... since the output of the cast inherits the offset from the input slice ...

It doesn't necessarily have to be the case. We are well free to output an intact, zero-offset array, which is logically equal to the current output with non-zero offset. This should require no API changes.

For 2, I think the requirement of outputting batches of unified schema (applying implicit casts when necessary) makes a lot of sense. However I'm wondering if we can do it in an less-intrusive way, for example, applying the casts explicitly by leveraging existing mechanisms like ScanOptions::projection (

compute::Expression projection;
).

Thanks.

@scott-routledge2

Copy link
Copy Markdown
Contributor

@pitrou @zanmato1984 Thanks for the explanations! I opened a new PR to address the casting issue as discussed. As for 2, I'm also happy to keep this PR open and investigate a cleaner way of ensuring output batches have the same schema. Although I was wondering if there should be a separate issue?

@zanmato1984

Copy link
Copy Markdown
Contributor

Although I was wondering if there should be a separate issue?

I think so. Moving on to a separate issue allow us to preserve the original purpose along with all the discussion history in the current issue/pr, which would make sense to the future readers.

zanmato1984 pushed a commit that referenced this pull request Dec 17, 2025
…ffset -> Binary offset types (#48171)

### Rationale for this change

Casting Binary offset -> Binary offset types relies on ZeroCopyCastExec, which propagates the offset of the input to the output. This can lead to larger allocations than necessary when casting arrays with offsets. 

See #43660 and
#43661 for more context.

### What changes are included in this PR?

Ensure output array has a small offset (it can still be non-zero since reusing the null bitmap requires in_offset % 8 == out_offset % 8)

### Are these changes tested?

Ran unit tests and benchmarked locally.

### Are there any user-facing changes?

No

* GitHub Issue: #43660

Authored-by: Scott Routledge <scott@bodo.ai>
Signed-off-by: Rossi Sun <zanmato1984@gmail.com>
@zanmato1984

Copy link
Copy Markdown
Contributor

Issue resolved by #48171, so closing this for now. Feel free to file a new issue/PR for the schema aligning issue, if it still is.

Mottl pushed a commit to Mottl/arrow that referenced this pull request May 26, 2026
…nary offset -> Binary offset types (apache#48171)

### Rationale for this change

Casting Binary offset -> Binary offset types relies on ZeroCopyCastExec, which propagates the offset of the input to the output. This can lead to larger allocations than necessary when casting arrays with offsets. 

See apache#43660 and
apache#43661 for more context.

### What changes are included in this PR?

Ensure output array has a small offset (it can still be non-zero since reusing the null bitmap requires in_offset % 8 == out_offset % 8)

### Are these changes tested?

Ran unit tests and benchmarked locally.

### Are there any user-facing changes?

No

* GitHub Issue: apache#43660

Authored-by: Scott Routledge <scott@bodo.ai>
Signed-off-by: Rossi Sun <zanmato1984@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants