linliu-code opened a new pull request, #689:
URL: https://github.com/apache/hudi-rs/pull/689

   > **Stacked on #688 → #687 → #686 → #682 → #678. Review those first.** This 
targets `main`, so it shows their commits alongside its own. The delta that 
belongs to this PR is the last commit: 2 files, +166/-6.
   
   Third and last slice of #685, and it closes the one question the ticket left 
open. **The answer turned out to be "don't add `spawn_blocking`"** — so this PR 
is a measurement, a decision, and the bound that makes the decision hold.
   
   ## The question
   
   #685 flagged it itself: a merge map that has spilled probes RocksDB 
synchronously, and once the merge is a `Stream` (#688) that probe runs on the 
task polling it rather than on a blocking thread. The ticket's own instruction 
was to measure before adding `spawn_blocking` for its own sake, "since that 
would reintroduce a crossing".
   
   ## The measurement
   
   `spilled_merge_blocking_duration` (ignored; run with `--release --ignored 
--nocapture`) times one `merge_base_batch` against a 50 000-key log map, with 
and without the spill engaged:
   
   | chunk rows | no-spill | spilled |
   |---|---|---|
   | 1024 | 0.4 – 1.1 ms | 5.7 – 6.1 ms |
   | 8192 | 2.8 ms | 39 – 42 ms |
   
   **The no-spill leg is the control, and it is what decides the question.** My 
first version of this benchmark did not have it, and without it the 40 ms 
figure looks like a spill problem. With it, the spill is a **~6× amplifier on 
work that is already synchronous and of the same kind**. Gating 
`spawn_blocking` on `merge_map_spilled()` (already available on the buffer 
trait) would treat the amplifier and leave a poll that is milliseconds long 
either way.
   
   Per-chunk CPU work on the polling task is how a scan or join operator 
behaves. Handing each chunk to a blocking pool would reintroduce the sync/async 
crossing #686 and #688 removed, and cost a task hop plus a buffer move per 
chunk, to shave single-digit milliseconds.
   
   ## What the numbers do say
   
   The cost is linear in a chunk's rows. That makes the chunk size the thing 
bounding how long one poll occupies its executor — and that bound was 
**inherited, not chosen**: the base read set no batch size, so it came from 
`parquet`'s own default of 1024 (`parquet-57.3.1`, `arrow_reader/mod.rs:171`, 
verified rather than assumed). That is a silent bound. A larger default 
upstream, or a caller passing a bigger batch size through here, multiplies 
every poll's cost with nothing failing.
   
   `base_read_options` now asks for it explicitly as `MERGE_CHUNK_ROWS`.
   
   **Behaviour change, small but real:** a parquet base file is unaffected — 
1024 is what it was already getting. A **lance** base file defaulted to 8192, 
so its merge chunks become 1024: the same rows in more chunks, and the bound is 
now uniform across base-file formats instead of differing eightfold by backend. 
`read_data` concatenates its stream, so the single-batch path returns exactly 
what it did before.
   
   ## How are the changes test-covered
   
   - [ ] N/A
   - [x] Automated tests (unit and/or integration tests)
   - [ ] Manual tests
     - [ ] Details are described below
   
   `test_a_merged_chunk_is_bounded_by_the_readers_own_batch_size` writes 5000 
rows in **one** row group and asserts no chunk exceeds the bound — so a chunk 
that followed the file's layout fails it. It also asserts the option directly, 
and that combination is deliberate: the bound agrees with parquet's default 
today, so no output-level assertion can distinguish the pin from the default, 
while the realistic future break is a caller passing a larger size (for 
instance making `hoodie.read.stream.batch_size` effective on the merge path — 
the gap recorded in #687).
   
   Mutation-checked, each reverted: dropping the pin, and raising it eightfold. 
Each fails that test.
   
   Verified locally: format, clippy over all targets and without default 
features, the full `hudi-core` suite both ways (1324 lib tests, 1303 without 
defaults), the gold parity sweep green across all fixtures and both reader 
versions, and `cargo check` of `hudi-cpp` and `hudi-python`. Fork PR, so none 
of it ran in CI — the numbers are local.
   
   ## Limits of the measurement
   
   Stated so nobody reads more into it than it supports:
   
   - The spill file is freshly written, so the page cache is warm and **the 
disk component is understated**. A cold spill file would be slower — though 
still bounded by the same chunk size, which is the point of pinning it.
   - One machine, single-threaded, release build. It is enough to separate 
"milliseconds" from "hundreds of milliseconds", which is the distinction the 
decision rests on, and not enough to quote as a throughput figure.
   
   If the spilled path ever does need to come off the executor, the hook is 
unchanged and cheap: `merge_map_spilled()` on the buffer trait, checked once 
per merge rather than per chunk.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to