AadhiKat commented on PR #861:
URL: https://github.com/apache/incubator-xtable/pull/861#issuecomment-5201780563

   @vinishjail97, I did profiling and it ended up correcting the previous 
numbers. Repro you can run yourself is at the bottom.
   
   **Where the time goes**
   
   On the table from the issue (16 vCPU / 64 GB, `local[8]`, GCS, Spark 3.4.2 / 
Delta 2.4.0 from our pom):
   
   ```
   v281561  getSnapshotAt = 21,133 ms   metadata() = 0 ms   schema() = 0 ms
   v281560  getSnapshotAt = 21,030 ms   metadata() = 0 ms   schema() = 0 ms
   ```
   
   `metadata()` showing 0 ms looks like it refutes your reading of 2.4.0, but 
it doesn't. `_protocol/_metadata` is a lazy val in `Snapshot.scala`, and the 
constructor ends with `logInfo(s"Created snapshot $this")`, whose toString 
includes `metadata=$metadata`. With INFO logging enabled (it was here), that 
forces the P&M reconstruction during construction, inside `getSnapshotAt`. With 
INFO off it would land on the first `metadata()` call instead. So you were 
right about the mechanism: the protocol/metadata reconstruction over the 
checkpoint is the biggest single cost, it just hides inside snapshot 
construction.
   
   For listing vs read, Delta's own log timestamps the boundary on each reload:
   
   ```
   05:37:27  Try to find Delta last complete checkpoint before version 281569
   05:37:31  Delta checkpoint is found at version 281560              ->  4 s   
discovery
   05:37:35  Loading version 281569 starting from checkpoint 281560   ->  4 s   
log segment build
   05:37:35  Created DeltaLogFileIndex(Parquet, numFilesInSegment: 459, 
totalFileSize: 6900706829)
   05:37:47  Created snapshot                                         -> 12 s   
checkpoint read
   ```
   
   Same shape on all 11 reconstructions across the runs below: discovery 4-5 s, 
segment build 3-5 s, read 11-12 s. So roughly 9 s listing + 12 s checkpoint 
read per commit. I'd originally assumed the listing was negligible since a flat 
`listFiles` of the whole 72,238-object dir takes 5.5 s, but 
`findLastCompleteCheckpointBefore` lists backwards in 1000-version windows 
(`Checkpoints.scala`), and at 459 parts every 10 commits one window spans ~46k 
objects. The segment build then lists again from the found checkpoint. Both 
passes happen per commit on main.
   
   (The issue said 454 parts / ~6.5 GB; it's 459 / 6.90 GB now. Table is live, 
it grew.)
   
   **A/B on the real table**
   
   Same jar, same machine, same source instance, same 6-commit backlog. Only 
difference: clearing the cached snapshot before each commit (= main) or not (= 
this patch). RELOAD run before and after REUSE to rule out page cache:
   
   ```
   RELOAD  21503  20575  20347  20409  20269      3 ms    total 103,106 ms / 6 
commits
   REUSE   20991      2     14      2     19      2 ms    total  21,030 ms / 6 
commits
   RELOAD  21710  20218  19866  20164  20398      2 ms    total 102,358 ms / 6 
commits
   ```
   
   The two RELOAD runs agree within 0.7%. Non-snapshot per-commit work is ~8 
ms, so the reconstruction is ~99.96% of the per-commit cost. (The last commit 
is ~3 ms even under RELOAD because `DeltaLog` caches the current snapshot 
itself, so `getSnapshotAt(latest)` is free.)
   
   **Repro**
   
   Builds a synthetic Delta table, checkpoints it, appends a backlog, then 
walks it through `DeltaConversionSource` twice: once clearing the cached 
snapshot per commit via reflection (main), once not (patch). Local filesystem, 
no credentials, JDK 11:
   
   ```
   javac -cp xtable-utilities_2.12-*-bundled.jar DeltaRepro.java
   java  -cp .:xtable-utilities_2.12-*-bundled.jar DeltaRepro /tmp/repro 160000 
5 20 60 120
   ```
   
   ```
   ### table: 160000 add-files, checkpoint 32 parts / 10,304,893 bytes
   
   backlog   RELOAD total   REUSE total    RELOAD/commit  REUSE/commit   ratio
   5              575 ms         133 ms         115 ms          26 ms     4.3x
   20            2521 ms         140 ms         126 ms           7 ms    18.0x
   60            7538 ms         117 ms         125 ms           1 ms    64.4x
   120          14105 ms          98 ms         117 ms           0 ms   143.9x
   ```
   
   RELOAD grows linearly with backlog length, REUSE stays flat: the checkpoint 
gets read once instead of N times. Which also means there's no single speedup 
number, and I shouldn't have implied there was — the longer the backlog, the 
bigger the win. To push it further, raise the backlog length, or raise 
`addFiles` / lower `spark.databricks.delta.checkpoint.partSize` to grow the 
checkpoint. Note at 160k files the checkpoint is only ~10 MB and Spark job 
overhead dominates, so this shows the shape, not the magnitude.
   
   <details>
   <summary><code>DeltaRepro.java</code></summary>
   
   ```java
   import io.delta.tables.DeltaTable;
   import java.lang.reflect.Field;
   import java.time.Instant;
   import java.util.ArrayList;
   import java.util.List;
   import org.apache.hadoop.fs.FileStatus;
   import org.apache.hadoop.fs.FileSystem;
   import org.apache.hadoop.fs.Path;
   import org.apache.spark.sql.SaveMode;
   import org.apache.spark.sql.SparkSession;
   import org.apache.spark.sql.delta.DeltaLog;
   import org.apache.xtable.delta.DeltaConversionSource;
   import org.apache.xtable.model.CommitsBacklog;
   import org.apache.xtable.model.InstantsForIncrementalSync;
   
   /**
    * Self-contained reproduction for apache/incubator-xtable PR #861.
    *
    * Builds a synthetic Delta table with a configurable number of add-files, 
checkpoints it, appends a
    * short backlog of commits, then walks that backlog through 
DeltaConversionSource twice:
    *
    *   RELOAD = today's main  : the snapshot is reconstructed per commit
    *   REUSE  = the patch     : the snapshot is reconstructed once and reused
    *
    * Vary the backlog length to see the unpatched arm pay the checkpoint read 
once PER COMMIT while
    * the patched arm pays it once in total. Everything is local -- no object 
storage, no credentials.
    *
    *   java -cp <xtable-bundled-jar>:. DeltaRepro /tmp/repro 160000 5 20 60 120
    */
   public class DeltaRepro {
   
   
       // Fully-qualified rather than the "delta" short name: the short name 
needs the
       // DataSourceRegister services entry, which the shaded xtable-utilities 
bundle has lost.
       // The FQCN resolves on any classpath, shaded or not.
       private static final String DELTA = 
"org.apache.spark.sql.delta.sources.DeltaDataSource";
   
       private static void clearCache(DeltaConversionSource src) throws 
Exception {
           for (String f : new String[] {"cachedSnapshot", "cachedTable", 
"cachedFileFormat"}) {
               Field field = DeltaConversionSource.class.getDeclaredField(f);
               field.setAccessible(true);
               field.set(src, null);
           }
       }
   
       private static DeltaConversionSource newSource(SparkSession spark, 
DeltaLog log, String path) {
           return DeltaConversionSource.builder()
                   .sparkSession(spark).deltaLog(log).deltaTable((DeltaTable) 
null)
                   .tableName("repro").basePath(path).build();
       }
   
       /** @return {totalMs, commits} */
       private static long[] walkBacklog(SparkSession spark, DeltaLog log, 
String path,
               Instant lastSync, boolean reloadEachCommit) throws Exception {
           DeltaConversionSource src = newSource(spark, log, path);
           CommitsBacklog<Long> backlog = src.getCommitsBacklog(
                   
InstantsForIncrementalSync.builder().lastSyncInstant(lastSync).build());
           long total = 0, count = 0;
           for (Long v : backlog.getCommitsToProcess()) {
               if (reloadEachCommit) {
                   clearCache(src);
               }
               long t = System.nanoTime();
               src.getTableChangeForCommit(v);
               total += (System.nanoTime() - t) / 1_000_000L;
               count++;
           }
           return new long[] {total, count};
       }
   
       public static void main(String[] args) throws Exception {
           String baseDir = args[0];
           int addFiles = Integer.parseInt(args[1]);
           int[] backlogs = new int[args.length - 2];
           int maxBacklog = 0;
           for (int i = 2; i < args.length; i++) {
               backlogs[i - 2] = Integer.parseInt(args[i]);
               maxBacklog = Math.max(maxBacklog, backlogs[i - 2]);
           }
   
           SparkSession spark = SparkSession.builder()
                   .appName("xtable-861-repro").master("local[8]")
                   .config("spark.ui.enabled", "false")
                   .config("spark.sql.extensions", 
"io.delta.sql.DeltaSparkSessionExtension")
                   .config("spark.sql.catalog.spark_catalog",
                           "org.apache.spark.sql.delta.catalog.DeltaCatalog")
                   .config("spark.hadoop.fs.file.impl", 
"org.apache.hadoop.fs.LocalFileSystem")
                   // small parts so a modest file count still yields a 
multi-part checkpoint,
                   // mirroring the shape of the table in the issue
                   .config("spark.databricks.delta.checkpoint.partSize", "5000")
                   .getOrCreate();
           spark.sparkContext().setLogLevel("ERROR");
   
           String path = baseDir + "/t" + addFiles;
           spark.range(addFiles).toDF("id").write().format(DELTA)
                   .option("maxRecordsPerFile", 1)
                   .option("delta.checkpointInterval", "100000")
                   .mode(SaveMode.Overwrite).save(path);
   
           DeltaLog log = DeltaLog.forTable(spark, path);
           log.checkpoint(log.update(false, scala.Option.empty()));
   
           for (int k = 0; k < maxBacklog; k++) {
               
spark.range(1).toDF("id").write().format(DELTA).mode(SaveMode.Append).save(path);
           }
   
           FileSystem fs = new 
Path(path).getFileSystem(spark.sessionState().newHadoopConf());
           long parts = 0, bytes = 0;
           for (FileStatus st : fs.globStatus(new Path(path + 
"/_delta_log/*.checkpoint*"))) {
               parts++;
               bytes += st.getLen();
           }
           System.out.printf("%n### table: %d add-files, checkpoint %d parts / 
%d bytes%n",
                   addFiles, parts, bytes);
           System.out.printf("%n%-9s %-14s %-14s %-14s %-14s %-8s%n",
                   "backlog", "RELOAD total", "REUSE total", "RELOAD/commit", 
"REUSE/commit", "ratio");
           System.out.println("-".repeat(78));
   
           for (int b : backlogs) {
               long latest = log.update(false, scala.Option.empty()).version();
               Path commitFile =
                       new Path(String.format("%s/_delta_log/%020d.json", path, 
latest - b));
               Instant lastSync =
                       
Instant.ofEpochMilli(fs.getFileStatus(commitFile).getModificationTime());
   
               long[] reload = walkBacklog(spark, log, path, lastSync, true);
               long[] reuse = walkBacklog(spark, log, path, lastSync, false);
   
               System.out.printf("%-9d %-14d %-14d %-14d %-14d %.1fx%n",
                       reload[1], reload[0], reuse[0],
                       reload[0] / Math.max(1, reload[1]), reuse[0] / 
Math.max(1, reuse[1]),
                       (double) reload[0] / Math.max(1, reuse[0]));
           }
           spark.stop();
       }
   }
   ```
   
   </details>
   
   **On the production-path concern**
   
   The snapshot feeds exactly two things in `getTableChangeForCommit`: 
`tableExtractor.table(snapshot, tableName)` and 
`convertToFileFormat(snapshot.metadata().format().provider())`. Everything 
`DeltaTableExtractor.table` populates is either constant for the table (name, 
basePath, log path) or derived from `metadata()` (schema, partition fields, 
layout, file format). Nothing reads the file list or per-version state. So the 
cache can only go stale on a Metadata/Protocol action, which is what the guard 
checks — and it checks the unfiltered action list 
(`DeltaIncrementalChangesState` stores `deltaLog.getChanges()` raw), so those 
actions can't have been filtered out before the check. The cache lives on one 
`DeltaConversionSource`, which is built fresh per sync run, so there's no 
cross-run or cross-table state.
   
   `latestCommitTime` is the one per-commit value, from your round-1 catch: it 
now comes from the commit's own `CommitInfo`, already present in 
`actionsForVersion`, no extra IO. It's persisted as the sync watermark, so a 
stale value there would mean reprocessing the tail on the next run.
   
   One thing you'll see in the diff: if a commit has no `CommitInfo` (or a null 
timestamp), the fallback is `getSnapshotAt(version).timestamp()`, so that 
commit pays a full reconstruction. It degrades to main's behavior rather than 
producing a wrong watermark, but a backlog of CommitInfo-less commits would get 
no benefit from the patch. It could be O(1) instead: `Snapshot.timestamp` is 
just the last commit file's mtime (`lastCommitTimestamp = 
deltas.last.getModificationTime` in `SnapshotManagement.scala`), so a 
`getFileStatus` on `_delta_log/<version>.json` gives the same value without 
touching the checkpoint. Can do that in this PR or as a follow-up, your call.
   
   Tests: `testAddColumns` is the real regression guard here (adds a column 
mid-backlog; if invalidation broke, later commits would carry the old schema 
and it fails). The new spy test asserts `getSnapshotAt` runs exactly once 
across an append backlog with output identical to main's. The two-run watermark 
test covers the reprocessing case. One caveat: the guard is sound for what 
`DeltaTableExtractor.table` reads today. If someone later derives something 
version-varying from the snapshot, the cache would serve it stale silently, and 
the spy test wouldn't catch it. I can add an assertion pinning the 
snapshot-derived fields if you want that enforced.
   
   If you'd like an operational escape hatch on top: the reuse can sit behind a 
conf key as a kill switch (ConversionSourceProvider already carries the conf, 
RunSync already takes --hadoopConfig). I'd default it on since output is 
identical either way and the users hitting this pathology are the least likely 
to find an opt-in flag — but whether the project wants that config surface at 
all is your call.
   
   **Correcting my own numbers**
   
   The PR description says "~2-3 min/commit -> ~3 s/commit". The patched half 
holds (matches the 210 commits in ~10 min I quoted), but the unpatched half 
doesn't reproduce: it's ~20.5 s/commit, not 2-3 minutes. That figure came from 
eyeballing a much messier run and shouldn't have gone in the description. 
Measured: ~8x end-to-end on a ~200-commit backlog (~80 min -> ~10 min, Iceberg 
writes dominate the patched path), and 4x to >100x on the isolated code path 
depending on backlog length, per the repro table. I've updated the description.
   
   **Why your 10 TB table showed nothing**
   
   Two drivers, and a 10 TB table can easily have neither. Checkpoint size is 
driven by add-file count, not bytes: this table is a tiny-file pathology, 
45,638,865 add-files -> 459 parts -> 6.90 GB. And the listing half is driven by 
`_delta_log` density, which is also part count: the discovery window here 
contains ~100 checkpoints x 459 parts ≈ 46k objects. A 10 TB table with sane 
file sizes and normal retention has a small checkpoint and a sparse log, so no 
per-commit penalty is what I'd expect. The shape that reproduces it is many 
small files plus a long, frequently-checkpointed history. My numbers are GCS; 
if you retry on S3 I'd expect the read half to transfer more cleanly than the 
listing half.
   
   What you asked for:
   
   | | |
   |---|---|
   | schema width | 256 columns |
   | partitioning | `year, month, day, hour` |
   | checkpoint cadence | every 10 commits |
   | checkpoint | 459 parts, 6.90 GB, 45,638,865 add-files |
   | `_delta_log` | 72,238 objects, 594.74 GB |
   | writer | Apache-Spark/3.5.4, Delta-Lake/3.2.0 |
   | reader | Spark 3.4.2, Delta 2.4.0 |
   | commit shape | blind append, ~130-1000 files per commit |
   
   
   
   
   


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