Fixate is an Apache Spark DataSource V2 connector that reads fixed-width (and newline-delimited) files at near-bare-metal throughput. All decompression and record parsing runs inside a native Odin shared library. Parsed rows are serialized directly into Spark's binary UnsafeRow format in off-heap memory and handed back via Java Project Panama with zero copy.
12 × 512 MiB gzip · 140-column flat layout · 6 GB compressed on disk · Spark 3.5 · 24-core / AVX-512 · 3 runs/phase
| Query | vs naive JVM parser | vs optimized JVM parser |
|---|---|---|
| Full 140-column scan | 2.1× faster | 1.5× faster |
| Column pruning (11 cols) | 20.0× | 14.7× |
COUNT(*) |
48.3× | 33.7× |
Filter + SUM (pushed down) |
55.8× | 42.4× |
Full-work scans run ~1.5–2.2× faster than even a hand-tuned mapPartitions JVM parser; once a query pushes down column pruning, predicates, or aggregates, Fixate skips work a row-producing parser cannot — 17–56× faster vs naive, 13–43× vs optimized JVM. Full per-phase table ↓
- Architecture Overview
- Compression / Input Format
- Threading Model
- Off-Heap Memory Layout
- DataSource V2 Class Flow
- Supported Spark Types
- Schema Definition & Column Mapping
- Query Pushdowns
- Reader Options
- Null Handling
- Parse Modes
- UnsafeRow Writer Invariant
- Build Instructions
- Testing
- Performance Benchmark
- Known Limitations
%%{init: {'theme':'dark'}}%%
flowchart TD
FS["Hadoop FileSystem\n(local / HDFS / S3 / GCS)"]:::blue
BGReader["FixateBackgroundReader\nScala daemon thread\nreads io_buffer_size chunks"]:::green
RB1["Ring Buffer 1\noff-heap raw input bytes\n(plaintext or gzip)\nPanama Arena"]:::blue
NativeEngine["Native Odin Engine\nruns on Spark task thread\nvia fixate_next_rows downcall"]:::orange
ZLIB["zlib-ng (if compression=gzip)\nstatically linked, built with AVX-512\nauto GZIP/zlib detection\nelse verbatim copy"]:::orange
SIMD["SIMD Delimiter Scanner\nAVX-512 (64B) → AVX2 (32B) → SSE2 (16B) → scalar\nchosen at init by CPUID"]:::orange
Parser["Field Parser\nSWAR int/long + fast-float\ndecimal i64/i128 fast path\nUnsafeRow serialization"]:::orange
BatchBuf["4 MB Batch Row Buffer\noff-heap UnsafeRow bytes"]:::blue
SparkTask["Spark Task Thread\nFixatePartitionReader.next()\nreusableRow.pointTo — zero copy"]:::green
Catalyst["Catalyst Execution Engine\nWhole-Stage Code Generation"]:::green
FS --> BGReader
BGReader -->|"writes raw input bytes\natomic write_ptr"| RB1
RB1 --> NativeEngine
NativeEngine --> ZLIB
NativeEngine --> SIMD
NativeEngine --> Parser
Parser --> BatchBuf
BatchBuf -->|"(addr, size) array\nisTrivial Panama downcall"| SparkTask
SparkTask --> Catalyst
classDef green fill:#3b1f4f,stroke:#c084fc,stroke-width:2px,color:#f1f5f9
classDef blue fill:#1f3b2f,stroke:#4ade80,stroke-width:2px,color:#f1f5f9
classDef orange fill:#1e3a5f,stroke:#60a5fa,stroke-width:2px,color:#f1f5f9
- Single off-heap arena. The entire memory footprint for one partition reader — the ring buffer, decompressor scratch, row buffers, blueprint trees, and filter tables — lives in one Panama
Arena.allocate(size, 64)call. The native engine never callsmalloc/freeduring parsing. - Batch pull API. The Spark task thread calls
fixate_next_rows(engine, 2048, ptrArray, sizeArray)and receives up toMAX_BATCH_SIZE(2048) UnsafeRow pointers per downcall. The downcall is annotatedLinker.Option.isTrivial()— no GC safepoint transition, no stop-the-world overhead. - SIMD delimiter scanning. At initialization the Odin engine resolves the fastest byte-search routine for the host CPU by CPUID: AVX-512 (64B) → AVX2 (32B) → SSE2 (16B) → scalar. The AVX-512 path (
simd.u8x64compare with a 64-bit lane mask) is chosen only whenavx512f+avx512bw+avx512vlare all present at runtime (avx512vlis required because the 64-lane compare legalizes through 256-bit masked compares); otherwise it falls back to AVX2 → SSE2 → scalar. Selection is purely runtime — the shared library still builds for baseline x86-64 and only executes the AVX-512 instructions on capable CPUs. A scalar tail handles the remaining bytes. - Kernel dispatch table. The byte-scan and numeric-parse kernels (
char_search,parse_long,parse_f64,parse_decimal) are resolved once at engine init by CPUID and bundled into a singleDispatchstruct on theEngineInstance(populated infixate_initialize). The per-field hot loops call straight througheng.dispatch.<kernel>— no per-call CPU-feature query or type-keyed re-selection — so a faster kernel can be slotted in behind the pointer without touching the loops. (This replaced the old singlechar_search_fnfield.) - SWAR integer parsing.
parse_long_fastparses 8 ASCII digits per 64-bit word — mask the low nibble and fold adjacent digit groups with three multiply/shift steps, afterswar_all_digitsvalidates the word is all digits (little-endian gated; a non-digit word falls back to the scalar loop). It also takes a width-aware fast path: ≤18-digit inputs skip the per-digit overflow-guard divide (10^18 − 1 cannot overflow i64), keeping the precise guard only for 19+ digit inputs. - Fast-float kernel.
parse_f64_fasttakes the Clinger fast path: accumulate the significant digits into a u64 mantissa and divide by 10^scale via an exactPOW10_F64table when digits ≤ 15 and scale ≤ 22 (both operands are exact in f64, so the single division is correctly rounded and bit-identical tostrconv.parse_f64). Exponent form, too many digits, or any junk byte falls back tostrconv.parse_f64. It is the default behindeng.dispatch.parse_f64. - Bulk ptr/size array read. Per batch refill,
FixatePartitionReader.next()bulk-copies the engine's off-heap row pointer/size arrays into reused JVMlong[]/int[]once (MemorySegment.copy). The per-row hot path then indexes the plain Java arrays instead of issuing aMemorySegment.getper row, removing the per-row Panama scope/bounds checks while the emittedUnsafeRowstays a zero-copy view into native memory. - zlib-ng decompression. Statically linked, compiled with AVX-512. zlib-ng runtime-dispatches AVX-512 variants relevant to the inflate/decompression path on capable CPUs —
inflate_fast_avx512,chunkmemset_safe_avx512, andcrc32_vpclmulqdq_avx512(gzip's CRC32 over decompressed bytes), plusadler32_avx512for zlib streams — gated by its own CPUID dispatch (nothing needs enabling). Initialized withwindowBits = 47(= 15 + 32) for automatic GZIP/zlib/deflate header detection. Only initialized when the input is gzip (compression=gzip). - Plaintext fast path. The default
compression=nonereads uncompressed input: bytes drained from Ring Buffer 1 are copied verbatim into the decode buffer (io_thread_copy_chunk) with no inflate step, and the decompressor is never set up. - Zero-copy inflate feed (no copy amplification).
io_thread_setup_stream_inputs(fixate-core/src/parser/io_thread.odin) hands zlib-ng a direct pointer into Ring Buffer 1's contiguous span up to the ring boundary — it never stages the unread bytes through a temporary buffer. When the unread span wraps the ring, the wrapped tail is consumed on the next call once the read index wraps to0. (The old path copied the entire unread RB1 span — up torb1_size— into arb1_temp_bufon every inflate step, while inflate only consumed enough input to fill its output buffer; that produced a hundreds-fold memcpy amplification that scaled withrb1_size. Therb1_temp_bufallocation has been removed entirely.) This change dropped a full-parse pass on a 12 × 1.2 GB gzip benchmark from ~410–440 s to ~138 s (~3×), and aperfprofile showed libcmemcpyfall from 44.3% to 0.3% of CPU — the workload is now genuinely parse-bound (~50% parsing, ~12% JVM, ~1% inflate). - Small fixed decode buffer. The native decode/inflate-output buffer (
local_buf) is a fixed 1 MB (FixateConstants.DECODE_BUFFER_SIZE), deliberately decoupled fromio_buffer_size(the disk-read chunk) — the two are independent concerns. The value is still threaded to the native engine as thedecode_buf_sizeparameter onfixate_initialize/fixate_get_required_memory_size. Aperfprofile showed inflate's CPU share rising monotonically with this buffer (≈3.5% at 1 MB → 4.4% at 4 MB → 5.5% at 16 MB): a larger buffer spills L2 and makes inflate's sequential output writes miss cache, while the parser saw no i-cache benefit from running longer between refills. So smaller is better, floored only by the need to comfortably exceed one record (~KB).
Fixate reads either plaintext or gzip-compressed input. The compression reader option selects the path; the default is none (plaintext) — gzip input must opt in with .option("compression", "gzip").
%%{init: {'theme':'dark'}}%%
flowchart TD
Opt["compression option\n(default: none)"]:::green
Validate["validateCompression()\npeek leading magic bytes"]:::green
Conflict{"declared format\nmatches content?"}:::green
FailFast["throw IllegalArgumentException\nclear mismatch / unsupported-format message"]:::green
SetCfg["EngineConfig.compressed\n(byte offset 6)"]:::blue
Drain["drain bytes from Ring Buffer 1"]:::orange
PathQ{"compressed?"}:::orange
Copy["io_thread_copy_chunk\nverbatim memcpy → decode buffer\n(decompressor never initialized)"]:::orange
Inflate["io_thread_decompress_chunk\nzlib-ng inflate (windowBits 47)\n→ decode buffer"]:::orange
Parse["record demarcation + field parsing"]:::orange
Opt --> Validate --> Conflict
Conflict -->|"mismatch / unsupported"| FailFast
Conflict -->|ok| SetCfg
SetCfg --> Drain --> PathQ
PathQ -->|"false (plaintext)"| Copy --> Parse
PathQ -->|"true (gzip)"| Inflate --> Parse
classDef green fill:#3b1f4f,stroke:#c084fc,stroke-width:2px,color:#f1f5f9
classDef blue fill:#1f3b2f,stroke:#4ade80,stroke-width:2px,color:#f1f5f9
classDef orange fill:#1e3a5f,stroke:#60a5fa,stroke-width:2px,color:#f1f5f9
Before opening the stream, FixatePartitionReader.validateCompression() peeks the file's leading bytes and throws a clear IllegalArgumentException if the declared compression contradicts the content, instead of silently emitting garbage (gzip read as plaintext) or hitting a cryptic inflate error. It recognizes these magic numbers via detectCompression:
| Format | Magic bytes | Engine support |
|---|---|---|
| gzip | 1f 8b |
supported (inflated) |
| zstd | 28 b5 2f fd / 37 a4 30 ec (dictionary) |
unsupported → error |
| xz | fd 37 7a 58 5a 00 |
unsupported → error |
| bzip2 | 42 5a 68 (BZh) |
unsupported → error |
| lz4 | 04 22 4d 18 |
unsupported → error |
| snappy (framed) | ff 06 00 00 |
unsupported → error |
The engine only supports gzip/zlib, so any other recognized format is reported as unsupported. A weak 2-byte zlib heuristic (looksLikeZlib) — deflate method with a header that is a multiple of 31 — is consulted only in the compression=gzip direction, because it collides with numeric plaintext (e.g. a field starting with 80…).
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant S as Spark Task Thread
participant BG as FixateBackgroundReader (daemon)
participant RB as Ring Buffer 1 (off-heap)
participant OE as Native Odin Engine
S->>S: new FixatePartitionReader(ctx)
S->>OE: fixate_initialize(blueprint, config, arena)
S->>BG: backgroundReader.start()
activate BG
BG->>RB: MemorySegment.copy(chunk → rb1_data)\nwritePtr += n
BG->>OE: fixate_notify_pushed() [sema_post]
loop batch pull
S->>OE: fixate_next_rows(engine, 2048, ptrs, sizes)
OE->>RB: read input bytes [readPtr advance]
OE->>OE: gzip → zlib-ng inflate, else copy verbatim → local_buf
OE->>OE: SIMD scan for delimiter
OE->>OE: parse fields → UnsafeRow in batch_row_buffer
OE-->>S: returns row count (or -2 if stalled)
alt stalled (RB1 empty, no EOF)
S->>OE: fixate_wait_for_data() [sema_wait]
BG->>RB: write more bytes
BG->>OE: fixate_notify_pushed()
end
S->>S: reusableRow.pointTo(addr, size) — zero copy
end
BG->>OE: fixate_signal_eof()
deactivate BG
S->>OE: fixate_destroy(engine)
S->>S: arena.close()
Thread 1 (FixateBackgroundReader): Opens the Hadoop FSDataInputStream and reads in io_buffer_size-byte chunks (default 4 MB). Writes into Ring Buffer 1 using MemorySegment.copy with wrap-around logic. When Ring Buffer 1 is full, parks for 100 microseconds and retries, recording stall count and total stall time in the RB1 header for telemetry. Calls fixate_notify_pushed (semaphore post) after each write. On EOF or exception, calls fixate_signal_eof.
Spark task thread: Calls fixate_next_rows in a tight loop. The native engine runs cooperatively on this thread — it drains Ring Buffer 1, decompresses, scans boundaries, parses fields, and fills the 4 MB batch buffer, then returns. If Ring Buffer 1 is empty and EOF is not yet signaled, the engine returns -2; the Scala layer then calls fixate_wait_for_data (semaphore wait) before retrying. On success, reusableRow.pointTo(null, addr, size) gives Spark a zero-copy view into the off-heap batch buffer.
%%{init: {'theme':'dark'}}%%
block-beta
columns 3
engine["EngineInstance\n+ EngineConfig copy"]:1
blueprint["FieldDefinition tree\n(blueprint)"]:1
rb1hdr["RB1 Control Header\n(capacity/write_ptr/read_ptr/eof/stalls)"]:1
rb1data["Ring Buffer 1 Data\nrb1_size bytes\n(compressed input)"]:1
localbuf["Local Decode Buffer\n1 MB (fixed)"]:1
rowbuf["Row Buffer\nmax_row_size + 64 KB\n(single-row scratch)"]:1
batchbuf["Batch Row Buffer\n4 MB\n(UnsafeRow bytes)"]:1
sched["ParseSchedule\nflat instruction array"]:1
filter["Filter Definitions\n(if pushed)"]:1
trailer["Trailer Pool\n(if skip_trailer_lines > 0)"]:1
cycles["Col Cycles Array\nu64 × field_count"]:1
%%{init: {'theme':'dark'}}%%
packet-beta
0-7: "capacity (i64)"
8-15: "write_ptr (i64) — Thread 1"
16-63: "_pad1 (48 bytes)"
64-71: "read_ptr (i64) — Odin"
72-127: "_pad2 (56 bytes)"
128-131: "eof (i32)"
132-135: "_padding"
136-143: "full_stalls_count (i64)"
144-151: "full_stalls_time_ns (i64)"
write_ptr and read_ptr are separated by a full cache line (64 bytes of padding each) to prevent false sharing between the Scala writer thread and the Odin reader. Both are monotonically increasing byte counters; modular index is ptr % capacity.
%%{init: {'theme':'dark'}}%%
classDiagram
direction TB
class FixateDataSource:::green {
+inferSchema() throws IAE
+getTable(schema, props) FixateTable
+supportsExternalMetadata() true
}
class FixateTable:::green {
+schema StructType
+capabilities BATCH_READ
+newScanBuilder(options) FixateScanBuilder
}
class FixateScanBuilder:::green {
-prunedSchema StructType
-limitValue Int
-pushedPredicatesArray Array~Predicate~
-pushedAgg Option~FixateAggSpec~
+pruneColumns(required)
+pushLimit(n) Boolean
+pushPredicates(preds) residual[]
+pushAggregation(agg) Boolean
+pushTableSample(...) Boolean
+build() FixateScan
}
class FixateScan:::green {
+readSchema() pruned or agg partial
+toBatch() FixateBatch
+supportedCustomMetrics() custom metrics
}
class FixateBatch:::green {
+planInputPartitions() one per file
+createReaderFactory() FixatePartitionReaderFactory
}
class FixatePartitionReaderFactory:::green {
+createReader(partition) FixatePartitionReader
}
class FixatePartitionReader:::green {
-arena Arena
-engineHandle MemorySegment
-reusableRow UnsafeRow
-backgroundReader FixateBackgroundReader
+next() Boolean
+get() InternalRow
+close()
+currentMetricsValues()
}
class FixateBackgroundReader:::green {
-readerThread Thread daemon
+start()
+stop()
+getAndResetBytesRead() Long
}
class NativeOdinEngine:::orange {
fixate_initialize()
fixate_next_rows()
fixate_set_aggregates()
fixate_destroy()
}
FixateDataSource --> FixateTable
FixateTable --> FixateScanBuilder
FixateScanBuilder --> FixateScan
FixateScan --> FixateBatch
FixateBatch --> FixatePartitionReaderFactory
FixatePartitionReaderFactory --> FixatePartitionReader
FixatePartitionReader --> FixateBackgroundReader
FixatePartitionReader --> NativeOdinEngine
classDef green fill:#3b1f4f,stroke:#c084fc,stroke-width:2px,color:#f1f5f9
classDef orange fill:#1e3a5f,stroke:#60a5fa,stroke-width:2px,color:#f1f5f9
FixateSchemaCompiler translates each Spark DataType into a native FieldDefinition. Any type not in the table below throws UnsupportedOperationException at scan-initialization time.
| Spark Type | Native Code | Stored as | Notes |
|---|---|---|---|
StringType |
0 | UTF-8 bytes (var-len section) | Latin-1 → UTF-8 expansion when encoding=ISO-8859-1 |
IntegerType |
1 | inline i32 in fixed slot |
Zero-trim fast parse |
LongType |
2 | inline i64 in fixed slot |
Zero-trim fast parse |
StructType |
3 | nested UnsafeRow bytes | Recursively compiled |
ArrayType |
4 | UnsafeArrayData bytes | Fixed element count; array_count metadata required |
DecimalType(p,s) |
5 | p ≤ 18: inline i64; p > 18: BigInteger bytes (var-len) |
See decimal fast-path below |
BooleanType |
6 | inline i32 (0 or 1) |
Custom true_value via field metadata |
DoubleType |
7 | inline f64 |
parse_f64_fast (Clinger fast path; falls back to strconv.parse_f64) |
FloatType |
8 | inline f32 |
parse_f64_fast, cast to f32 |
ShortType |
9 | inline i16 |
Bounds-checked integer |
ByteType |
10 | inline u8 |
Bounds-checked integer |
DateType |
11 | inline i32 (epoch-days) |
Parsed via a general format pattern (see Date & timestamp parsing); falls back to integer epoch-days when no format is given |
TimestampType |
12 | inline i64 (epoch-µs) |
Parsed via a general format pattern, optionally tz-aware (offset token in the value or timezone metadata; default UTC); falls back to integer epoch-micros when no format is given |
Boolean parsing rules. A field is true if (after whitespace trimming):
- The trimmed value exactly matches the string in schema metadata key
true_value. - Or it is
true/TRUE(case-insensitive, 4 characters). - Or it is one of the single characters
T,t,Y,y, or1.
DateType / TimestampType are parsed by a general pattern interpreter (native fixdatetime) driven by the column's format metadata — any well-formed fixed-width pattern works, not a fixed allow-list:
- Date/time tokens:
yyyy/uuuu(4-digit year),yy/uu(2-digit →20yy),MM/M,dd/d,HH/H,mm/m,ss/s,SSS…(fractional seconds). Any other character is a literal that must match (-,/,:,,.), and'…'quotes a literal section (e.g. the'T'inyyyy-MM-dd'T'HH:mm:ss). Examples:yyyyMMdd,yyyyMM(day ⇒ 1),uuuuMMdd,dd-MM-yyyy,yyyyMMddHHmmss,yyyy-MM-dd HH:mm:ss.SSS. - Year token semantics:
yyyy(year-of-era) requires a positive year and rejects year 0 (1 BCE). The prolepticuuuutoken accepts year 0. Pathological/overflowing year values yield null rather than error. - Calendar validation: months and per-month day counts are validated with correct leap-year rules —
2023-02-29(non-leap) and2021-04-31are rejected (null inPERMISSIVE, error inFAILFAST). - Timezone (Timestamp only). The instant is resolved as: (1) an offset token in the value —
X(+05/Z),XX(+0530),XXX(+05:30) — read per row (DST-correct); else (2) the column'stimezonemetadata (a fixed offset); else (3) UTC. DST-bearing IANA zones intimezonemetadata are rejected — use an offset token for per-row DST correctness.DateTypeis timezone-independent. - No format ⇒ the field is read as an integer epoch value (epoch-days for Date, epoch-micros for Timestamp).
Fixate requires an explicit schema. FixateDataSource.inferSchema always throws IllegalArgumentException. Supply the schema via .schema(...).
| Key | Type | Description |
|---|---|---|
offset |
Long |
Byte offset within the record. Omit to use cumulative auto-offset from preceding fields. |
length |
Long |
Byte length of this field. Required for all leaf fields (or use col.<name>.length option). |
array_count |
Long |
Number of fixed elements in an ArrayType field. Required. |
element_length |
Long |
Byte length of each array element. Required for arrays. |
true_value |
String |
Custom string that evaluates to true for BooleanType fields. |
null_value |
String |
Per-column null sentinel. When the trimmed field exactly matches this string, the column is null. Applies to any leaf field type. |
format |
String |
Date/timestamp parse pattern for DateType / TimestampType (see Date & timestamp parsing). When absent, the field is parsed as an integer epoch value. |
timezone |
String |
For TimestampType: the zone a value's wall-clock is in when the format carries no offset token. Fixed offsets only (UTC, Z, +05:30, -06:00, GMT-6). Absent ⇒ UTC. DST-bearing IANA zones (e.g. America/Chicago) are rejected — use an offset token in format for per-row DST correctness. |
import org.apache.spark.sql.types._
val schema = new StructType()
.add("id", LongType, nullable = true,
new MetadataBuilder().putLong("offset", 0).putLong("length", 10).build())
.add("name", StringType, nullable = true,
new MetadataBuilder().putLong("offset", 10).putLong("length", 30).build())
.add("amount", DecimalType(15, 4), nullable = true,
new MetadataBuilder().putLong("offset", 40).putLong("length", 15).build())
spark.read
.format("io.codeberg.hectormiguel.spark.fixate.FixateDataSource")
.schema(schema)
.option("record_length", "55")
.option("compression", "gzip") // omit (or "none") for plaintext input
.load("/data/input/*.gz")Field lengths can also be supplied as reader options when modifying the schema is inconvenient:
spark.read
.format("io.codeberg.hectormiguel.spark.fixate.FixateDataSource")
.schema(schema)
.option("col.id.length", "10")
.option("col.name.length", "30")
.load(path)When both metadata and col.<name>.length are present, metadata takes precedence.
%%{init: {'theme':'dark'}}%%
flowchart TD
Query["Spark query plan"]:::green
PruneQ{"Column pruning\npossible?"}:::green
Prune["pruneColumns()\nOnly requested columns\ncompiled into blueprint"]:::green
LimitQ{"LIMIT\npushed?"}:::green
Limit["pushLimit(n)\nEngine stops after n records\nSpark re-checks as safety net"]:::green
FilterQ{"Filter on top-level column\n(EqualTo/LT/LTE/GT/GTE/IsNull/IsNotNull/In/StringStartsWith\nfor supported types)?"}:::green
Filter["pushPredicates()\nAND/OR/NOT trees +\ncomparison ops on String/Int/Long/Short/Byte/Date/Float/Double/Decimal\nIsNull + IsNotNull on any top-level column\nIn on Int/Long/Short/Byte/String\nStringStartsWith on String\n(Boolean: EqualTo only)\nTimestamp / nested → Spark"]:::green
FilterFB["Unsupported filters\nreturned to Spark"]:::green
AggQ{"Global aggregate\nno GROUP BY?"}:::green
Agg["pushAggregation()\nCOUNT★, COUNT(col), SUM, MIN, MAX\nApplies any pushed filters, then accumulates\none partial row/partition; Spark merges"]:::green
AggFB["Aggregation stays\nin Spark"]:::green
Scan["FixateScan\n(build)"]:::green
Query --> PruneQ
PruneQ -->|yes| Prune
PruneQ -->|no| Scan
Prune --> LimitQ
LimitQ -->|yes| Limit
LimitQ -->|no| FilterQ
Limit --> FilterQ
FilterQ -->|supported| Filter
FilterQ -->|unsupported| FilterFB
Filter --> AggQ
FilterFB --> AggQ
AggQ -->|"accepted\n(COUNT★/COUNT(col)/SUM/MIN/MAX\nover supported types)"| Agg
AggQ -->|"declined\n(GROUP BY or unsupported func/type)"| AggFB
Agg --> Scan
AggFB --> Scan
classDef green fill:#3b1f4f,stroke:#c084fc,stroke-width:2px,color:#f1f5f9
FixateScanBuilder implements five DSv2 pushdown interfaces (SupportsPushDownRequiredColumns, SupportsPushDownLimit, SupportsPushDownV2Filters, SupportsPushDownAggregates, SupportsPushDownTableSample):
| Interface | Behaviour |
|---|---|
SupportsPushDownRequiredColumns |
Only columns referenced by the query are compiled into the native blueprint and parsed. |
SupportsPushDownLimit |
The limit value is forwarded to the native engine. pushLimit normally returns false so Spark re-enforces the limit as a safety net (it returns true only when header/trailer routing is active, so the engine owns the limit). |
SupportsPushDownV2Filters |
pushPredicates compiles each top-level V2 Predicate tree — AND/OR/NOT nodes plus leaves — to the native filter program, all-or-nothing per top-level predicate (anything not fully compilable is returned as residual for Spark). Supported leaves: comparison ops (=, <, <=, >, >=) on top-level StringType, IntegerType, LongType, ShortType, ByteType, DateType, FloatType, DoubleType, and DecimalType; = only on BooleanType; IS_NULL / IS_NOT_NULL on any top-level column; IN on IntegerType, LongType, ShortType, ByteType, and StringType; STARTS_WITH on StringType. OR composition (which the legacy V1 list API could not express) now pushes natively as one pass. Additional constraints: out-of-range Int/Short/Byte literals are NOT pushed (Spark evaluates post-scan); -0.0 Float/Double literals are normalized to +0.0; NaN/±Infinity literals are not pushed; filter windows exceeding record_length are not pushed. Predicates on TimestampType or nested columns (and any literal that fails to coerce) are returned to Spark for post-scan evaluation. |
SupportsPushDownAggregates |
Global COUNT(*), COUNT(col), SUM, MIN, MAX with no GROUP BY. Decimal SUM is intentionally not pushed (see Aggregate-pushdown type coverage). Aggregate pushdown composes with filter pushdown — the native accumulator path applies any pushed filters before accumulating, so only matching rows are counted/summed. Each partition emits one partial-aggregate row; Spark performs the final merge. supportCompletePushDown returns false. Declined under mode=DROPMALFORMED — the accumulator fast-path treats a malformed field as null and never drops a record, so it cannot reproduce DROPMALFORMED's row-drop; Spark does the full parse + drop + aggregate instead (FAILFAST/PERMISSIVE push normally). |
SupportsPushDownTableSample |
pushTableSample pushes a TABLESAMPLE into the native engine as a deterministic, seed-respecting Bernoulli sample that skips the field parse for discarded rows. Returns true (Spark drops its Sample operator) only for a [0, fraction) window without replacement, fraction in (0,1); otherwise declines (false). |
A filter on a top-level column is pushed to the native engine only for the types below; the byte position is resolved against the original schema, so a filter on a column pruned from the output still locates its bytes. Date filters carry the column's format (epoch-day comparison); a Date column with no format compares as integer epoch days. Everything else falls back to post-scan evaluation in Spark.
Additional constraints applied before pushing any filter:
- Out-of-range literals: an
Int/Short/Byteliteral that falls outside the column type's range is NOT pushed — Spark evaluates post-scan with the correct semantics. -0.0normalization: a-0.0Float/Double literal is normalized to+0.0(adding 0.0) so the pushed comparand's bits match Spark's-0.0 == +0.0semantics.- NaN / ±Infinity: non-finite Float/Double literals are never pushed; the native comparator uses raw IEEE compares (
NaN != NaN) which diverges from Spark. - Record bounds: a filter whose source window (
offset + length) exceeds the declaredrecord_lengthis not pushed.
| Spark column type | = (EqualTo) |
< / <= / > / >= (ordering) |
IsNull / IsNotNull |
In |
StringStartsWith |
Notes |
|---|---|---|---|---|---|---|
StringType |
✅ | ✅ | ✅ | ✅ | ✅ | byte-wise comparison |
IntegerType |
✅ | ✅ | ✅ | ✅ | ❌ | out-of-range literals not pushed |
LongType |
✅ | ✅ | ✅ | ✅ | ❌ | |
ShortType |
✅ | ✅ | ✅ | ✅ | ❌ | shares the native integer path; out-of-range not pushed |
ByteType |
✅ | ✅ | ✅ | ✅ | ❌ | shares the native integer path; out-of-range not pushed |
DateType |
✅ | ✅ | ✅ | ❌ | ❌ | format-aware; compared as epoch days |
BooleanType |
✅ | ❌ | ✅ | ❌ | ❌ | equality only (no ordering predicates) |
FloatType / DoubleType |
✅ | ✅ | ✅ | ❌ | ❌ | parsed via the fast-float kernel; compared at the column's precision (Float at f32); -0.0 normalized; NaN/Inf not pushed |
DecimalType(p,s) |
✅ | ✅ | ✅ | ❌ | ❌ | field and literal parsed to the same unscaled p/s and compared exactly |
TimestampType |
❌ | ❌ | ✅ | ❌ | ❌ | comparison ops not pushed → post-scan in Spark (literal timezone-parity needs verification); IsNull/IsNotNull still pushed |
nested StructType / ArrayType |
❌ | ❌ | ❌ | ❌ | ❌ | only top-level scalar columns support filter pushdown |
Pushed aggregates are global only (no GROUP BY). COUNT(*) works regardless of column type. Date/Timestamp MIN/MAX compare as epoch days / epoch micros; Boolean compares as false < true. SUM is numeric-only (Spark disallows SUM on date/timestamp/boolean) — and Decimal SUM is intentionally declined (see the note below).
| Aggregate | Supported source types |
|---|---|
COUNT(*) |
any (no column referenced) |
COUNT(col) |
String + numeric (Byte/Short/Int/Long/Float/Double/Decimal) + Date + Timestamp + Boolean (any leaf type — COUNT(col) only needs null detection) |
MIN / MAX |
numeric (Byte/Short/Int/Long/Float/Double/Decimal) + Date (epoch days) + Timestamp (epoch micros) + Boolean (false < true) |
SUM |
Byte/Short/Int/Long → Long; Float/Double → Double. Decimal(p,s) SUM is NOT pushed — see note below. |
Why Decimal SUM is not pushed. Spark 3.5's V2 aggregate-pushdown rewrite always casts the pushed partial column back to the original Sum.child type (Decimal(p,s)) before re-summing. A per-partition partial that exceeds precision p overflows to NULL at that cast. No choice of partial type survives: declaring the partial as Decimal(p,s) causes the native partial value itself to overflow; declaring it as Decimal(p+10,s) causes Spark's forced CAST(partial AS Decimal(p,s)) to overflow. Since Spark always narrows back to the source precision, FixateAggregateCompiler declines Decimal SUM entirely and lets Spark compute it with its own lossless widening to Decimal(p+10,s). MIN/MAX/COUNT over Decimal still push normally (their partials are actual element values that fit within Decimal(p,s)).
If any aggregate in the query references an unsupported function (e.g. distinct), or a type not listed for that aggregate, the entire aggregation is declined and Spark performs it.
All option names support both snake_case (canonical) and camelCase (legacy, deprecated). The canonical snake_case form is preferred.
| Option | Default | Description |
|---|---|---|
compression |
none |
Input format: none (aliases uncompressed/plain/plaintext, or empty string) reads plaintext; gzip (alias gz) inflates gzip/zlib natively. Any other value throws. A declared value that contradicts the file's magic bytes fails fast. |
mode |
FAILFAST |
Parse error mode: FAILFAST, PERMISSIVE, or DROPMALFORMED. |
blank_as_null |
true |
Treat whitespace-only fields as null. |
zero_numerics_as_null |
false |
Treat any numeric field whose parsed value is exactly 0 as null. |
encoding |
UTF-8 |
Character encoding: UTF-8 (or UTF8), US-ASCII (or ASCII), ISO-8859-1 (or LATIN1). |
decimal_rounding_mode |
HALF_UP |
Decimal rounding: HALF_UP, HALF_EVEN (Banker's rounding), or DOWN (truncate). |
pre_touch_arena |
true |
Touch every page of the off-heap arena on init to pre-fault pages and avoid page-fault latency during parsing. |
null_guard |
"" (disabled) |
A global sentinel string (e.g. "NULL", "N/A") treated as null in every column after trimming. |
column_name_of_corrupt_record |
_corrupt_record |
Schema column that receives raw malformed bytes in PERMISSIVE mode. |
record_length |
0 |
Fixed record length in bytes. 0 means newline-delimited. |
line_delimiter |
\n |
Record delimiter byte (single UTF-8 character). |
skip_header_lines |
0 |
Leading lines to treat as header records. |
skip_trailer_lines |
0 |
Trailing lines to treat as trailer records. |
header_indicator |
H |
First byte identifying a header-type record (multi-record-type files). |
trailer_indicator |
T |
First byte identifying a trailer-type record. |
data_indicator |
D |
First byte identifying a data record. |
record_type_column |
record_type |
Schema column used to route header/trailer/data records. |
rb1_size |
1048576 (1 MB) |
Ring Buffer 1 size in bytes. Increase to 2–8 MB when I/O is faster than decompression. |
io_buffer_size |
4194304 (4 MB) |
JVM-side Hadoop read buffer size in bytes (the disk-read chunk). Independent of the native decode buffer, which is a fixed 1 MB. |
Custom metrics are exposed per partition in the Spark UI:
| Metric | High value indicates |
|---|---|
rb1_full_stalls_count / rb1_full_stalls_time_ms |
Odin cannot consume compressed data fast enough; increase rb1_size or simplify the schema. |
empty_stalls_count / empty_stalls_time_ms |
The parser is starved for input (Hadoop I/O is the bottleneck); increase io_buffer_size or rb1_size. (These measure the parser's input-wait stalls — they always have, despite the former rb2_-prefixed names; there is no second ring buffer.) |
uncompressed_bytes_read |
Total uncompressed (decompressed, or verbatim for plaintext) bytes processed by the task, summed in the Spark SQL UI. A cheap running counter incremented by inflate's produced bytes per step. The same total is also published to a driver-side named LongAccumulator (fixate_uncompressed_bytes) for machine-readable tooling, since DSv2 custom metrics are not exposed as task accumulables. |
For best CPU utilization, set spark.task.cpus = 2 so the Scala I/O daemon and the Spark task thread do not compete for a single core.
Four independent null mechanisms can be combined:
- Blank-as-null (
blank_as_null = true, default) — A field that is entirely whitespace after positional slicing is treated as null. This is the primary mechanism for space-padded fixed-width files. - Null guard sentinel (
null_guard) — A specific string that, when matched exactly after trimming, marks any field null. Applied globally to all columns. - Per-column null sentinel (
null_valuefield metadata) — A sentinel string scoped to a single column. When the trimmed field exactly matches the column'snull_value, that column is null. This composes with the globalnull_guard. - Zero-as-null (
zero_numerics_as_null = false, default) — When enabled, any numeric field whose parsed value is0is treated as null.
Pushed COUNT(col) / SUM / MIN / MAX apply the same null definition as the full-parse path: global blank_as_null / null_guard, the per-column null_value sentinel (carried into the native FixateAggDef), and zero_numerics_as_null. So an aggregate ignores exactly the rows Spark would see as null. Specifically:
COUNT(*)counts every DATA record (including all-null rows) and is never null.COUNT(col)counts only non-null values; the result is never null (0 if none).SUM/MIN/MAXignore nulls and return NULL (not0) when there were zero non-null values.
Each partition emits one partial-aggregate row; Spark performs the final cross-partition merge (supportCompletePushDown = false).
| Mode | Behaviour on malformed record |
|---|---|
FAILFAST (default) |
Engine sets error_flagged = true. fixate_next_rows returns -1. FixatePartitionReader.next() throws SparkException. |
PERMISSIVE |
Failed fields are set to null (see fixed-width contract below). If column_name_of_corrupt_record exists in the schema, the raw record bytes are written to it as a UTF-8 string. |
DROPMALFORMED |
Malformed records are silently discarded and never emitted. |
A record is considered malformed if:
- In fixed-length mode (
record_length > 0): the record byte count does not equalrecord_length. - A field fails a bounds check (parsed integer outside the target type's range).
- A decimal parse fails entirely.
When a fixed-width record fails bounds validation (its byte count does not equal record_length), PERMISSIVE nulls ALL non-corrupt fields and routes the raw bytes to _corrupt_record — it does NOT attempt to salvage leading fields that appear to be present. In fixed-width framing, a record that fails bounds validation cannot have trusted field boundaries: a "present" leading field may be a truncation artifact. This differs deliberately from Spark CSV PERMISSIVE, which salvages parsed tokens up to the parse failure. Only the per-field parse-failure path (a field whose value fails to parse, e.g. a non-numeric integer) preserves successfully-parsed sibling fields; the short/oversize-record path null-alls. (Verified in ParseModeEdgeSpec Gap#3 test.)
Records that exceed the native decode buffer (~1 MiB, FixateConstants.DECODE_BUFFER_SIZE) are treated as malformed and honor the configured parse mode rather than always raising a hard error. Under DROPMALFORMED, the oversize record is silently dropped and parsing continues. Under PERMISSIVE, the buffered prefix is routed to _corrupt_record and the remainder is drained. Under FAILFAST, the engine flags an error and the task fails. (Verified in ParseModeEdgeSpec E2/BUG-004 tests.)
The native UnsafeRow writers are built around a hard invariant that contributors must preserve:
Every field in a row is written exactly once — a value OR a null, never both.
row_writer_init zeroes the entire null bitset once up front (and nested-array writers do the same via write_nested_array_init). Because the bitset starts all-zero, the typed row_writer_write_* procedures no longer clear the per-field null bit — only row_writer_write_null ever sets a bit. This removed a per-field read-modify-write on a hot cache line (measurable at 100+ columns/row) and the write-after-write hazard it created between adjacent same-word field writes.
The consequence for record builders: null only the fields a record type does NOT populate; never "null all fields then overwrite". Pre-nulling a field that is later written a value would leave its null bit set after the value write, silently nulling a populated column. (This was a real bug in the header/trailer row builders — write_header_row / write_trailer_row now skip the fields they populate and null only the rest.)
A secondary parser optimization: parse_long_fast skips the per-digit overflow-guard divide for inputs of ≤18 digits, since 10^18 − 1 < i64 max cannot overflow; the precise per-digit guard is kept only for 19+ digit inputs.
- Odin compiler (LLVM-based, latest stable)
- CMake (to build zlib-ng)
- Java 21 (OpenJDK 21;
--enable-previewis required) - Maven 3.x
- Optional cross-compile toolchains:
aarch64-linux-gnu-gccfor Linux ARM64x86_64-w64-mingw32-gccfor Windows x64x86_64-apple-darwin-clang/aarch64-apple-darwin-clangfor macOS
# 1. Build native library for the host platform.
# Cross-compiles other targets if their toolchains are present.
# Copies all artifacts under fixate-spark/src/main/resources/natives/<platform>/
make build-native
# 2. Compile the Scala connector (requires step 1)
make build-scala
# 3. Both together
make build
# Debug build (bounds checking enabled, no optimizations)
make -C fixate-core libfixate_core.so DEBUG=1
# Clean everything
make clean| Platform | Resource path |
|---|---|
| Linux x86_64 | natives/linux_x64/libfixate_core.so |
| Linux ARM64 | natives/linux_arm64/libfixate_core.so |
| macOS x86_64 | natives/osx_x64/libfixate_core.dylib |
| macOS ARM64 | natives/osx_arm64/libfixate_core.dylib |
| Windows x64 | natives/windows_x64/fixate_core.dll |
| Platform | Native lib | Bundled in JAR | Tests |
|---|---|---|---|
| Linux x86_64 | ✅ host build | ✅ | ✅ run in CI |
| Linux ARM64 | ✅ cross (aarch64-gcc) | ✅ | cross-compiled; the shared SIMD-128 path runs on x86, native NEON only on an arm64 host |
| Windows x64 | ✅ cross (mingw-w64) | ✅ | cross-compiled; not executed in CI |
| macOS x64 / ARM64 | ❌ | ❌ | needs a Darwin toolchain (osxcross) or a macOS runner |
All Linux/Windows artifacts are cross-compiled from one x86_64 host (make build-native); zlib-ng is statically linked into each (no system libz dependency at runtime). macOS needs an Apple toolchain not available via apt.
The Linux x86_64 native library is compiled with -microarch:x86-64-v3, which sets x86-64-v3 as the baseline ISA (Haswell 2013+, AMD Excavator 2015+). This enables BMI/BMI2 (tzcnt, pdep, pext), FMA, and unaligned SSE/AVX access in scalar code. Any cloud or EMR instance type in active use today meets this baseline — only pre-2013 x86 hardware (before Intel Haswell / AMD Excavator) would not. The ARM64 and Windows cross-compiled libraries do not inherit this flag and set their own architecture-appropriate defaults.
SIMD paths (AVX-512 delimiter scan, AVX2, SSE2) are still selected at runtime by CPUID, so a Haswell CPU without AVX-512 uses the AVX2 path without error — the -microarch:x86-64-v3 flag only affects the scalar code generation baseline.
scripts/perf_analyze.py is a streamlined native perf-data analysis script for the Fixate engine. It processes a perf.data file (and optional perf stat output) and emits a structured Markdown report covering per-event self-time filtered to the native .so, call-graph top callers, and instruction-level hotspots for chosen symbols. See the script's docstring for usage.
The Panama FFI and Spark internals require these flags (already set in the Makefile MAVEN_OPTS for test and benchmark targets):
--enable-preview
--enable-native-access=ALL-UNNAMED
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.lang.invoke=ALL-UNNAMED
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=java.base/java.net=ALL-UNNAMED
--add-opens=java.base/java.nio=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED
--add-opens=java.base/sun.nio.cs=ALL-UNNAMED
--add-opens=java.base/sun.security.action=ALL-UNNAMED
--add-opens=java.base/java.security=ALL-UNNAMED
--add-opens=java.base/sun.util.calendar=ALL-UNNAMED
The sun.util.calendar open is required by Spark's date/time conversion path, which is exercised when DateType / TimestampType columns are produced.
# Native Odin unit tests (SIMD scanner, ring buffer, decimal serialization)
make test-native
# Spark integration tests
make test-scala
# Both
make testThe suite is 271 tests: 190 Scala/Spark integration specs (src/test/scala) and 81 native Odin unit tests (*_test.odin), all passing.
The native suite is one set of tests gated by when ODIN_ARCH + runtime CPU-feature checks (not per-arch suites): the cross-platform paths (scalar, 128-bit SIMD, parsing/decimal/datetime/aggregate) run everywhere; the x86 AVX2/AVX-512 paths run only on amd64, and AVX-512 only when the CPU advertises it. CI executes the suite on the x86_64 build host, so it covers the x86 SIMD + shared paths; ARM64 is build-verified (cross-compiled), not executed — its NEON path runs only on an arm64 runner.
CI runs on a self-hosted Forgejo Actions runner (Codeberg) using a prebuilt image (ci/Containerfile) that bakes the whole toolchain — JDK 21, Maven, Node, CMake, Odin, and the mingw-w64 / aarch64 cross toolchains — plus a pre-warmed Maven repo, so runs do no per-run setup. Each push:
- runs the native (Odin) + Scala (Spark) test suites,
- builds the engine and cross-compiles the Windows x64 + Linux ARM64 libraries,
- packages the multi-arch JAR and uploads it as a build artifact.
The zlib-ng build is cached on the runner keyed by the submodule commit (rebuilt only when the submodule is bumped). On pushes to master, the JAR is additionally published as a dated release (tag build-<UTC timestamp>), one per master build.
The benchmark (PerformanceBenchmark) compares Fixate against two independently-toggled JVM baselines:
- Baseline 1 — naive substring
.map. Reads each file as text and parses columns withsubstring+trim+.toInt/.toBigDecimal. Toggle with-Dbenchmark.run.baseline(defaulttrue). - Baseline 2 — optimized hand-rolled JVM parser. Uses
.mapPartitionswith a reusedRowand scratch buffer, index slicing instead ofsubstring, and a fall-through integer parse. Toggle with-Dbenchmark.run.baseline2(defaultfalse).
The two flags are independent — you can run either, both, or neither.
# 0.5 GB per file, 5 files, nested layout, with Baseline 1 (naive) comparison
make benchmark BENCHMARK_SIZE=0.5 BENCHMARK_FILES=5 BENCHMARK_BASELINE=truemake benchmark wires BENCHMARK_BASELINE to -Dbenchmark.run.baseline. To enable Baseline 2 (or other system properties), pass them via MAVEN_OPTS / the exec:java invocation directly.
12 files × 512 MiB gzip (6.00 GB compressed on disk) · 140-column flat layout · 3 runs/phase · 24-core box · AVX-512 native build · Spark 3.5.0. The flat layout exercises every flat parse path (var-len String, all inline numerics, small-decimal i64 and large-decimal i128/BigInteger, format-driven and integer-epoch Date/Timestamp). Baseline 1 = naive substring .map; Baseline 2 = optimized hand-rolled .mapPartitions JVM parser. Both baselines parse all 140 columns (the fair comparison).
| Phase | Query | Naive (s) | Opt. JVM (s) | Fixate (s) | vs Naive | vs Opt. JVM |
|---|---|---|---|---|---|---|
| 1 | Full schema scan — all 140 columns | 122.44 | 86.27 | 59.00 | 2.08× | 1.46× |
| 2 | Column pruning — 11 columns | 124.33 | 91.45 | 6.22 | 20.0× | 14.7× |
| 3 | LIMIT 100000 |
1.89 | 1.59 | 0.85 | 2.22× | 1.87× |
| 4 | Predicate pushdown — str_1 = 'strval_3' (~10% selective) |
149.89 | 115.05 | 8.68 | 17.3× | 13.3× |
| 5 | COUNT(*) |
122.95 | 85.75 | 2.54 | 48.3× | 33.7× |
| 6 | SUM over 7 numeric columns |
122.95 | 88.56 | 4.15 | 29.7× | 21.4× |
| 7 | Projection + predicate — 4 cols, int_1 < 500000 |
147.57 | 116.26 | 2.72 | 54.2× | 42.7× |
| 8 | Filter + SUM(int_2) where int_1 < 500000 |
147.24 | 111.79 | 2.64 | 55.8× | 42.4× |
| 9 | Selective predicate — int_1 < 500000 |
148.27 | 115.74 | 5.64 | 26.3× | 20.5× |
| 10 | Filter on a pruned column | 147.81 | 112.59 | 2.77 | 53.4× | 40.7× |
Reading the table. The JVM-side optimizations are real — Baseline 2 is ~1.4× faster than the naive substring baseline. Yet on the full-work scan (Phase 1) Fixate is still 2.08× faster than naive and 1.46× faster than the hand-tuned JVM parser on raw parse alone. The dramatic phases (2, 4–10) are where a query pushes column pruning, predicates, or aggregates into the native engine: a row-producing JVM parser must materialize every column of every row regardless, while Fixate skips that work entirely — 17–56× vs naive, 13–43× vs the optimized JVM.
These are flat-layout numbers (the scalar-parse story across all 14 type groups). The nested struct/array layout was not part of this measurement run.
Eliminating the per-step copy of the entire unread Ring-Buffer-1 span into a temp buffer (see Zero-copy inflate feed above) was the largest single performance win to date. On a 12 × 1.2 GB gzip dataset, a full-parse pass dropped from ~410–440 s to ~138 s (~3×), and a perf profile showed libc memcpy fall from 44.3% to 0.3% of CPU. The workload is now genuinely parse-bound (~50% parsing, ~12% JVM, ~1% inflate), so further gains come from the field-parsing kernels rather than the I/O path.
| Property | Default | Description |
|---|---|---|
benchmark.size.gb |
0.5 |
Target uncompressed size per file in GB. |
benchmark.num.files |
5 |
Number of benchmark files to generate / read. |
benchmark.run.baseline |
true |
Run Baseline 1 (naive substring .map). |
benchmark.run.baseline2 |
false |
Run Baseline 2 (optimized hand-rolled JVM parser). |
benchmark.layout |
nested |
Record shape: flat (140 scalar columns, record length 1711) or nested (140 scalar + 30 struct/array columns, record length 1951). The flat layout covers every flat parse path (incl. large-decimal i128/BigInteger and integer-epoch Date/Timestamp). |
benchmark.phases |
1,2,3,4,5,6,7,8,9,10 |
Comma-separated list of phases to run. |
| Phase | Description |
|---|---|
| 1 | Full schema scan — all columns parsed. |
| 2 | Column-pruned scan — 11 columns selected. |
| 3 | LIMIT 100000 query. |
| 4 | Predicate pushdown — str_1 = 'strval_3' (~10% selective). |
| 5 | COUNT(*) aggregate — no columns parsed, pure row-iteration cost. |
| 6 | SUM aggregate over 7 numeric columns (int_1, int_2, int_3, long_1, long_2, dec_1, dec_2). |
| 7 | Projection + predicate — select(str_1, str_2, int_1, int_2).filter(int_1 < 500000). |
| 8 | Filter + SUM aggregate — sum(int_2) where int_1 < 500000. |
| 9 | Selective predicate — filter(int_1 < 500000). |
| 10 | Filter on a pruned column — select(str_1, str_2).filter(int_1 < 500000). |
Benchmark data files are stored under benchmark_data/<layout>/benchmark_N.gz and regenerated only when missing.
DateType/TimestampTypeparse a generalformatpattern (see Date & timestamp parsing) with full calendar/leap-year validation. With noformat, the field is read as an integer epoch value (epoch-days for Date, epoch-micros for Timestamp). Timestamp timezone is taken from an offset token in the value, else thetimezonemetadata, else UTC; DST-bearing IANA zones intimezonemetadata are rejected (a fixed column offset can't represent DST — use an offset token in theformatinstead). The proleptic year-of-era tokenuuuuaccepts year 0 (1 BCE); the calendar year-of-era tokenyyyyrejects year 0. Pathological / overflowing year values yield null rather than error.- Filter pushdown is limited to top-level scalar columns. Comparison ops (
EqualTo/LessThan/LessThanOrEqual/GreaterThan/GreaterThanOrEqual) are pushed forStringType,IntegerType,LongType,ShortType,ByteType,DateType,FloatType,DoubleType, andDecimalType;EqualToonly forBooleanType.IsNull/IsNotNullare pushed for any top-level column.Inis pushed forInt/Long/Short/Byte/String.StringStartsWithis pushed forString. Timestamp comparison filters are deliberately left to Spark pending timezone-parity verification. Filters on nestedStructType/ArrayTypecolumns, out-of-range literals, and filter windows exceedingrecord_lengthare not pushed. NaN and ±Infinity Float/Double literals are not pushed. - Decimal SUM is not pushed.
SUMoverDecimal(p,s)is declined because Spark 3.5's V2 SUM rewrite casts the pushed partial column back toDecimal(p,s)before re-summing, causing any partition partial that exceeds precisionpto overflow to NULL. Spark computes decimal SUM itself with lossless widening.MIN/MAX/COUNTover Decimal still push. - PERMISSIVE fixed-width null-all. A short/malformed fixed-width record (byte count ≠
record_length) under PERMISSIVE nulls ALL fields and routes raw bytes to_corrupt_record— field boundaries in a truncated record are unreliable. This differs from Spark CSV PERMISSIVE, which salvages parsed tokens up to the failure point. - Aggregate pushdown is partial only. Each partition emits one partial-aggregate row and Spark performs the final cross-partition merge;
supportCompletePushDownreturnsfalse. (Pushed filters and a pushed aggregate do compose — filtered rows are excluded from the accumulation.) - No schema inference. A schema must be supplied via
.schema(...). - One partition per file. Each matched file maps to exactly one
InputPartition. There is no intra-file splitting. - Fixed-width
ArrayTypeonly. Arrays are read as a fixed-count slice of the record. Variable-length or delimited arrays are not supported. - Trailing trailer records dropped by LIMIT. When a
LIMITis pushed and the engine stops early, the trailingskip_trailer_linesrecords may not be buffered yet; they are silently dropped. This is a known limitation of the cooperative row-count limit mechanism.