Skip to content

Commit 3ef0be7

Browse files
committed
[query] Add Hail <-> Arrow schema conversion
Any proposed open columnar format that we intend to evolve hail to make use of can be described with an Arrow schema, making Arrow's types a common denominator for future work in this direction. This change adds the mapping between Hail physical types and that schema, in both directions, in a new `is.hail.types.arrow` package. `ArrowSchemaConversion` converts a PStruct to an Arrow Schema and back. There's a 1:1 mapping between Arrow's nullability and PType requiredness, except at the root struct, which Arrow gives no nullability and which always reads back as required. Reading takes the reference genomes in scope: it resolves each locus's genome by name and checks that the stored contigs match it. Mapping: - bool, int32, int64, float32, float64: Arrow primitives (signed ints) - str, binary: utf8, binary (32-bit offsets) - array<T>: list<element: T> - set<T>: list<element: T> with `hail.set` - dict<K, V>: map<entries: struct<key: K, value: V>>, keysSorted=true, and keys are always non-nullable - struct: struct - tuple: struct with fields "0", "1", ... plus `hail.tuple` - call: int32 with `hail.call`, metadata {"encoding":1} - locus: int64 packed as (contig_index << 32) | position, with `hail.locus` storing the reference genome name and ordered contigs. Every locus type reads back as PPackedLocus, whose representation is this storage - interval<T>: struct<start, end, includes_start, includes_end> with `hail.interval` - ndarray: Arrow's canonical `arrow.variable_shape_tensor` (struct<data: list<T>, shape: fixed_size_list<int32>[ndim]>), which arrow-java does not provide Extension metadata is a JSON object. Arrow -> Hail accepts only what Hail -> Arrow produces and rejects anything else with a clear error. That includes unregistered extension types, dictionary encoding (on any field, including structural children such as map entries), child fields on primitive types and duplicate field names. Types that cannot be stored (RNG state, void) are also rejected. Extension types do not create Arrow vectors, since the columnar writer will drive parquet-java directly. Declare arrow-vector 18.3.0 (Spark 4.1.2's version) as a compile-only dependency now that Hail uses it directly.
1 parent de32429 commit 3ef0be7

4 files changed

Lines changed: 912 additions & 0 deletions

File tree

‎hail/hail/package.mill‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ object `package` extends HailScalaModule:
7373
`breeze` :: "2.1.0",
7474
`blas` :: "3.0.4",
7575
`arpack` :: "3.0.4",
76+
`arrow-vector` :: "18.3.0",
7677
`avro` :: "1.12.1",
7778
`commons-codec` :: "1.19.0",
7879
`commons-io` :: "2.21.0",
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package is.hail.types.arrow
2+
3+
import is.hail.types.physical._
4+
import is.hail.utils.fatal
5+
import is.hail.variant.ReferenceGenomeEnv
6+
7+
import scala.jdk.CollectionConverters._
8+
9+
import org.apache.arrow.vector.types.FloatingPointPrecision
10+
import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType, Schema}
11+
12+
/** Conversion between Hail physical types and the Arrow schema of the columnar format.
13+
*
14+
* Arrow nullability carries Hail requiredness at every level below the root, with one exception:
15+
* dict keys are always non-nullable, so a dict whose key type is optional reads back with a
16+
* required key type. The root struct has no Arrow nullability, so it always reads back as
17+
* required.
18+
*
19+
* Every locus type reads back as a [[PPackedLocus]], whose representation is the Arrow storage.
20+
*
21+
* Arrow -> Hail accepts exactly the Arrow types that Hail -> Arrow produces.
22+
*/
23+
object ArrowSchemaConversion {
24+
ExtensionTypes.register()
25+
26+
val ElementFieldName: String = "element"
27+
val EntriesFieldName: String = "entries"
28+
val KeyFieldName: String = "key"
29+
val ValueFieldName: String = "value"
30+
31+
private val Int32 = new ArrowType.Int(32, true)
32+
private val Int64 = new ArrowType.Int(64, true)
33+
private val Float32 = new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)
34+
private val Float64 = new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE)
35+
36+
private val IntervalFieldNames =
37+
IndexedSeq("start", "end", "includes_start", "includes_end")
38+
39+
private val TensorFieldNames = IndexedSeq("data", "shape")
40+
41+
def toArrowSchema(t: PStruct): Schema =
42+
new Schema(t.fields.map(f => toArrowField(f.name, f.typ)).asJava)
43+
44+
def toArrowField(name: String, t: PType): Field = {
45+
def field(arrowType: ArrowType, children: Field*): Field =
46+
mkField(name, !t.required, arrowType, children: _*)
47+
48+
def child(name: String, t: PType): Field =
49+
toArrowField(name, t)
50+
51+
t match {
52+
case _: PBoolean => field(ArrowType.Bool.INSTANCE)
53+
case _: PInt32 => field(Int32)
54+
case _: PInt64 => field(Int64)
55+
case _: PFloat32 => field(Float32)
56+
case _: PFloat64 => field(Float64)
57+
case _: PString => field(ArrowType.Utf8.INSTANCE)
58+
case _: PBinary => field(ArrowType.Binary.INSTANCE)
59+
case _: PCall => field(CallExtensionType.Current)
60+
case t: PLocus => field(new LocusExtensionType(t.rg.name, t.rg.contigs))
61+
case t: PInterval =>
62+
field(
63+
IntervalExtensionType,
64+
child(IntervalFieldNames(0), t.pointType),
65+
child(IntervalFieldNames(1), t.pointType),
66+
mkField(IntervalFieldNames(2), nullable = false, ArrowType.Bool.INSTANCE),
67+
mkField(IntervalFieldNames(3), nullable = false, ArrowType.Bool.INSTANCE),
68+
)
69+
case t: PNDArray =>
70+
field(
71+
VariableShapeTensorExtensionType,
72+
mkField(
73+
TensorFieldNames(0),
74+
nullable = false,
75+
ArrowType.List.INSTANCE,
76+
child(ElementFieldName, t.elementType),
77+
),
78+
mkField(
79+
TensorFieldNames(1),
80+
nullable = false,
81+
new ArrowType.FixedSizeList(t.nDims),
82+
mkField(ElementFieldName, nullable = false, Int32),
83+
),
84+
)
85+
case t: PSet => field(SetExtensionType, child(ElementFieldName, t.elementType))
86+
case t: PDict =>
87+
field(
88+
new ArrowType.Map( /*keysSorted=*/ true),
89+
mkField(
90+
EntriesFieldName,
91+
nullable = false,
92+
ArrowType.Struct.INSTANCE,
93+
child(KeyFieldName, t.keyType.setRequired(true)),
94+
child(ValueFieldName, t.valueType),
95+
),
96+
)
97+
case t: PArray => field(ArrowType.List.INSTANCE, child(ElementFieldName, t.elementType))
98+
case t: PTuple => field(TupleExtensionType, t.fields.map(f => child(f.name, f.typ)): _*)
99+
case t: PStruct =>
100+
field(ArrowType.Struct.INSTANCE, t.fields.map(f => child(f.name, f.typ)): _*)
101+
case _ =>
102+
fatal(s"field '$name': Hail type ${t.virtualType} cannot be stored in the columnar format")
103+
}
104+
}
105+
106+
def fromArrowSchema(schema: Schema, rgs: ReferenceGenomeEnv): PCanonicalStruct =
107+
PCanonicalStruct(structFields(schema.getFields.asScala.toIndexedSeq, rgs), required = true)
108+
109+
def fromArrowField(field: Field, rgs: ReferenceGenomeEnv): PType = {
110+
val name = field.getName
111+
val required = !field.isNullable
112+
val children = field.getChildren.asScala.toIndexedSeq
113+
114+
def unsupported(reason: String): Nothing =
115+
fatal(
116+
s"field '$name' of Arrow type ${field.getType} is not part of the columnar format: $reason"
117+
)
118+
119+
def checkChildren(expected: IndexedSeq[String]): Unit =
120+
if (children.map(_.getName) != expected)
121+
unsupported(
122+
s"expected child fields ${expected.mkString("[", ", ", "]")}, found ${children.map(_.getName).mkString("[", ", ", "]")}"
123+
)
124+
125+
def onlyChild(): Field =
126+
if (children.length == 1) children.head
127+
else unsupported(s"expected 1 child field, found ${children.length}")
128+
129+
def convert(f: Field): PType = fromArrowField(f, rgs)
130+
131+
def requiredChild(f: Field): Field =
132+
if (f.isNullable) unsupported(s"child field '${f.getName}' must be non-nullable")
133+
else f
134+
135+
def leaf(t: PType): PType =
136+
if (children.isEmpty) t
137+
else unsupported(s"expected no child fields, found ${children.length}")
138+
139+
// Rejects what the Arrow type of `f` does not show: dictionary encoding, and an extension type
140+
// that is not registered, which reads as its storage type. Child fields that are not
141+
// converted by `fromArrowField` need this check too.
142+
def checkEncoding(f: Field): Unit = {
143+
if (f.getDictionary != null)
144+
unsupported(s"field '${f.getName}' is dictionary encoded")
145+
if (!f.getType.isInstanceOf[ArrowType.ExtensionType])
146+
Option(f.getMetadata.get(ArrowType.ExtensionType.EXTENSION_METADATA_KEY_NAME)).foreach {
147+
n => unsupported(s"field '${f.getName}' has unregistered extension type '$n'")
148+
}
149+
}
150+
151+
checkEncoding(field)
152+
153+
field.getType match {
154+
case _: CallExtensionType if field.getType == CallExtensionType.Current =>
155+
leaf(PCanonicalCall(required))
156+
case t: CallExtensionType =>
157+
unsupported(s"unknown call encoding ${t.encoding}")
158+
case t: LocusExtensionType =>
159+
val rg = rgs.get(t.referenceGenome).getOrElse(
160+
fatal(s"field '$name': reference genome '${t.referenceGenome}' is not defined")
161+
)
162+
if (rg.contigs != t.contigs)
163+
fatal(
164+
s"field '$name': the stored contigs of reference genome '${t.referenceGenome}' " +
165+
"differ from the contigs of the defined reference genome"
166+
)
167+
leaf(PPackedLocus(rg, required))
168+
case SetExtensionType =>
169+
PCanonicalSet(convert(onlyChild()), required)
170+
case IntervalExtensionType =>
171+
checkChildren(IntervalFieldNames)
172+
val IndexedSeq(start, end, includesStart, includesEnd) = children
173+
val pointType = convert(start)
174+
if (convert(end) != pointType)
175+
unsupported("interval 'start' and 'end' have different types")
176+
for (f <- Seq(includesStart, includesEnd)) {
177+
checkEncoding(f)
178+
if (f.isNullable || f.getType != ArrowType.Bool.INSTANCE || !f.getChildren.isEmpty)
179+
unsupported(s"interval '${f.getName}' must be a non-nullable bool")
180+
}
181+
PCanonicalInterval(pointType, required)
182+
case TupleExtensionType =>
183+
val fields = children.map { c =>
184+
val idx = c.getName.toIntOption.filter(i => i >= 0 && i.toString == c.getName).getOrElse(
185+
unsupported(s"tuple field name '${c.getName}' is not a non-negative integer")
186+
)
187+
PTupleField(idx, convert(c))
188+
}
189+
if (fields.map(_.index).distinct.length != fields.length)
190+
unsupported("tuple field names are not distinct")
191+
PCanonicalTuple(fields, required)
192+
case VariableShapeTensorExtensionType =>
193+
checkChildren(TensorFieldNames)
194+
val IndexedSeq(data, shape) = children
195+
checkEncoding(data)
196+
checkEncoding(shape)
197+
val nDims = shape.getType match {
198+
case t: ArrowType.FixedSizeList => t.getListSize
199+
case t => unsupported(s"tensor 'shape' must be a fixed_size_list, found $t")
200+
}
201+
requiredChild(shape): Unit
202+
if (shape.getChildren.size != 1 || convert(shape.getChildren.get(0)) != PInt32(true))
203+
unsupported("tensor 'shape' must contain non-nullable int32")
204+
if (requiredChild(data).getType != ArrowType.List.INSTANCE || data.getChildren.size != 1)
205+
unsupported("tensor 'data' must be a list")
206+
val elementType = convert(requiredChild(data.getChildren.get(0)))
207+
if (elementType.containsPointers)
208+
unsupported(s"tensor element type ${elementType.virtualType} is not supported")
209+
PCanonicalNDArray(elementType, nDims, required)
210+
case t: ArrowType.ExtensionType =>
211+
unsupported(s"unknown extension type '${t.extensionName}'")
212+
case ArrowType.Bool.INSTANCE => leaf(PBoolean(required))
213+
case Int32 => leaf(PInt32(required))
214+
case Int64 => leaf(PInt64(required))
215+
case Float32 => leaf(PFloat32(required))
216+
case Float64 => leaf(PFloat64(required))
217+
case ArrowType.Utf8.INSTANCE => leaf(PCanonicalString(required))
218+
case ArrowType.Binary.INSTANCE => leaf(PCanonicalBinary(required))
219+
case ArrowType.List.INSTANCE => PCanonicalArray(convert(onlyChild()), required)
220+
case t: ArrowType.Map =>
221+
if (!t.getKeysSorted) unsupported("map keys must be sorted")
222+
val entries = requiredChild(onlyChild())
223+
checkEncoding(entries)
224+
if (entries.getType != ArrowType.Struct.INSTANCE || entries.getChildren.size != 2)
225+
unsupported("map entries must be a struct with two fields")
226+
val key = requiredChild(entries.getChildren.get(0))
227+
PCanonicalDict(convert(key), convert(entries.getChildren.get(1)), required)
228+
case ArrowType.Struct.INSTANCE =>
229+
PCanonicalStruct(structFields(children, rgs), required)
230+
case _ => unsupported("unsupported type")
231+
}
232+
}
233+
234+
private def structFields(fields: IndexedSeq[Field], rgs: ReferenceGenomeEnv)
235+
: IndexedSeq[PField] = {
236+
val duplicates = fields.map(_.getName).groupBy(identity).collect {
237+
case (name, names) if names.length > 1 => name
238+
}
239+
if (duplicates.nonEmpty)
240+
fatal(
241+
s"duplicate field names are not part of the columnar format: ${duplicates.mkString(", ")}"
242+
)
243+
fields.zipWithIndex.map { case (f, i) =>
244+
PField(f.getName, fromArrowField(f, rgs), i)
245+
}
246+
}
247+
248+
private def mkField(name: String, nullable: Boolean, arrowType: ArrowType, children: Field*)
249+
: Field =
250+
new Field(name, new FieldType(nullable, arrowType, /*dictionary=*/ null), children.asJava)
251+
}

0 commit comments

Comments
 (0)