GGraziadei commented on PR #16827:
URL: https://github.com/apache/iceberg/pull/16827#issuecomment-5361125612

   ## JMH: Hilbert curve math vs Z-order interleaving
   
   Answering the question about whether the JMH run compares against Z-order 
interleaving on the same inputs, and the request for a relative number. It 
does, and the figures are below.
   
   > **Scope note.** Sections 1, 2 and 4 describe the branch as pushed. Section 
3 measures an allocation fix that is **not yet pushed**; it is reported here 
because the benchmark is what surfaced the defect, but the current diff does 
not yet contain it.
   
   ### Method
   
   `HilbertByteUtilsBenchmark` and `ZOrderByteUtilsBenchmark` are structurally 
matched: identical `NUM_ENTRIES = 10_000_000`, identical `new Random(42)` seed, 
identical setup loop. Both arms therefore see byte-for-byte identical input, 
and both were executed in a single JMH invocation so they share one JIT and GC 
context.
   
   ```
   JMH 1.37 · JDK 17.0.19 · 1 fork · 1 thread · SingleShotTime
   5 warmup + 15 measurement iterations · -Xmx8g · gc profiler
   1 op = one pass over 10M rows, 64 bits per column
   ```
   
   One methodological caveat worth stating up front: the committed benchmark 
classes declare no `@Warmup` and only 5 iterations. Measured that way, 
confidence intervals reach ±43% and — more seriously — the reported allocation 
rates are dominated by JIT compilation artifacts rather than by the code under 
test. Running the unmodified Z-order benchmark under the two configurations 
yields 396 MB/op without warmup versus 3.71 MB/op with it, a 
two-order-of-magnitude discrepancy attributable entirely to measurement rather 
than to any code change. All figures below use warmup and 15 iterations and are 
consequently **not** comparable to a default run of the committed classes.
   
   ### 1. Time
   
   ```
   s/op, lower is better
   2 cols  Hilbert  ████████████████████████▏                 14.64
           Z-order  ██████▏                                    3.71
   3 cols  Hilbert  ███████████████████████████████▌          19.09
           Z-order  █████████                                  5.43
   4 cols  Hilbert  ██████████████████████████████████████    22.88
           Z-order  ████████████▏                              7.45
   ```
   
   | Columns | Hilbert (s/op) | Z-order (s/op) | Ratio | Added cost |
   |--------:|---------------:|---------------:|------:|-----------:|
   | 2 | 14.644 ± 0.447 | 3.707 ± 0.119 | 3.95x | +1.09 µs/row |
   | 3 | 19.094 ± 0.512 | 5.433 ± 0.100 | 3.51x | +1.37 µs/row |
   | 4 | 22.881 ± 0.537 | 7.452 ± 0.278 | 3.07x | +1.54 µs/row |
   
   **`hilbertIndex` costs 3.1–4.0x a pure Z-order interleave on identical 
inputs.** This is a structural rather than incidental result: `hilbertIndex` 
issues the same `ZOrderByteUtils.interleaveBits` call and adds 
`axesToTranspose` on top, so the ratio is precisely the price of the curve 
transform. The ratio decreases with dimensionality because the interleave grows 
linearly in the number of columns while the transpose does not.
   
   ### 2. Attribution
   
   Stack profiler, 4 columns, percentages of RUNNABLE thread state.
   
   ```
   Hilbert                                Z-order
   axesToTranspose      ██████ 30.8%      interleaveBits   ██████████ 49.8%
   interleaveBits       ███ 15.1%         unattributed     ██████████ 50.0%
   hilbertIndex         ▌ 3.1%
   read/writeBigEndian  ▏ 0.8%
   unattributed         ██████████ 50.0%
   ```
   
   Within Hilbert the curve transform costs approximately twice the interleave 
it wraps (30.8% versus 15.1%), which corroborates the ~3x aggregate through an 
independent measurement path. Half the samples are unattributed, which is 
expected for a sampling profiler against inlined frames; these proportions 
should be read as indicative rather than exact.
   
   ### 3. Allocation
   
   The benchmark surfaced a defect worth fixing independently of the timing 
question. `hilbertIndex` allocated a `long[numColumns]` and a `new 
byte[numColumns][bytesPerColumn]` **per invocation**, i.e. per row, while the 
output `ByteBuffer` was already correctly reused. At four columns that is six 
objects per row and 60M objects per pass.
   
   ```
   bytes per op, log10 scale
   before  Hilbert  ███████████████████████████████████         1.77 GB
           Z-order  ████████████████████████▌                   3.71 MB
   after   Hilbert  ██████████████▌                              6.7 KB
           Z-order  ██████████████▌                              6.6 KB
   ```
   
   | | Hilbert | Z-order |
   |---|---:|---:|
   | before | 1.774 GB/op, 8 GC pauses | 3.711 MB/op, 0 GC pauses |
   | after | 6 743 B/op, 0 GC pauses | 6 570 B/op, 0 GC pauses |
   
   The remedy is an overload accepting caller-owned scratch — the same `reuse` 
convention `ZOrderByteUtils` applies throughout — with `SparkHilbertUDF` 
holding it in the `ThreadLocal`s it already maintains. The three-argument 
overload is unchanged and still allocates, so no existing caller is affected. 
The residual ~6.6 KB/op is JMH's own per-op baseline and is identical across 
both arms.
   
   **Hilbert now allocates no more than Z-order, and neither triggers a single 
garbage collection across 10M rows.**
   
   ### 4. Interpretation and limits
   
   - **The allocation fix produced no measurable time improvement** (−0.2%, 
+2.6%, −9.5% across the three widths). The inconsistent signs identify this as 
noise. The −9.5% at four columns is regression to the mean of a prior outlier, 
whose interval was ±2.463 before and ±0.537 after. This is the expected 
outcome: the objects were short-lived and young-generation-cheap, and GC 
accounted for 0.5% of wall time even before the fix. The benefit is reduced 
allocator pressure across concurrent Spark tasks, which a single-threaded 
micro-benchmark cannot exhibit.
   - **The 3x applies to the combine step in isolation.** In an end-to-end 
rewrite that step is a minor term next to I/O, shuffle, and the per-column 
ordered-byte conversion. The measured end-to-end penalty is **+15.4%** (169.8s 
→ 196.0s), and the gap between 3x and 15.4% is itself evidence that the combine 
does not dominate.
   - **Absolute values are machine-specific.** These were collected on a 
developer workstation with other applications resident. Since both arms 
executed in the same fork against the same inputs, the Hilbert-to-Z-order ratio 
is the robust quantity; the absolute s/op figures are not.
   


-- 
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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to