Skip to content

Commit d3a859e

Browse files
committed
Split straight into the buffers Arrow will read
Three copies stood between an Arrow geometry column and the Arrow batch of pieces it splits to, none of them necessary. Reading copied every vertex out of the source buffer into a vector, one push_back at a time. For interleaved 2D coordinates - which is what to_geoarrow asks geopandas for - that buffer already *is* an array of Coord, as the static_assert on its size has always said, and splitLineStringGrid's first act is to walk its input transforming every vertex into a second vector anyway. So the kernels now take a CoordSpan, and the common layout is read in place. A z ordinate to step over, or x and y held apart as GeoParquet writes them, still have to be gathered; those keep a buffer that is reused from one geometry to the next. Writing allocated a fresh LinePieces or PolygonPieces per geometry, then concatenated its coordinates onto the batch and rebased its offsets one at a time, widening size_t to int32 as it went. But endPiece() already records coordinates.size(), which is absolute in whatever buffer it is filling - so the kernels now append into an accumulator the batch owns, the offsets come out already correct, and the concatenate-and-rebase blocks are gone. Holding the offsets as int32, the width an Arrow list takes, means the accumulator's vectors are handed to Arrow as they stand. Appending needs the polygon kernel's two closing passes - back to world coordinates, and the mirror correction - bounded to what each call adds rather than run over the accumulator as a whole. Two new Catch2 cases pin that down, and they use a scaled, offset, y-downwards transform on purpose: under the identity a coordinate transformed twice is indistinguishable from one transformed once, and an earlier version of these tests passed with both bounds removed. One trap found by measuring rather than reading: reserve() asks for an exact capacity rather than growing geometrically, so reserving exactly what one polygon needs recopied the whole accumulator once per polygon. That made polygons 31% slower before it was fixed. Extension-only, on 20k linestrings to 300k pieces and 4k polygons to 188k pieces, best of 15 runs, alternating binaries over three rounds: linestrings 43.4 -> 37.4 ms (-14%), polygons 61.2 -> 57.3 ms (-6%). End to end through split_linestrings is unchanged, as expected: it is dominated by geopandas building shapely geometries from the result, which is around ten times the extension's share and untouched here. Pieces are bit-identical to before on both geometry types. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqdWshmD4AUHhrqeMh86GS
1 parent f3cdf0b commit d3a859e

5 files changed

Lines changed: 328 additions & 95 deletions

File tree

extension/src/geoarrow.cpp

Lines changed: 112 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,36 @@ class Coordinates {
206206
return {x[x_base + vertex], y[y_base + vertex]};
207207
}
208208

209+
/// Vertices [begin, end) as a run the split kernels can read.
210+
///
211+
/// Interleaved 2D coordinates already *are* a run of Coord - that is what
212+
/// the static_assert on sizeof(Coord) at the top of this file pins down -
213+
/// so the span points straight into the Arrow buffer and nothing is
214+
/// copied. Every other layout has a gap between one vertex's y and the
215+
/// next's x, or holds x and y apart altogether, so those are gathered into
216+
/// a buffer that is reused from one geometry to the next.
217+
/// Are the coordinates laid out exactly as a run of Coord? Interleaved
218+
/// with nothing but x and y is, provided the slice this view starts at
219+
/// lands on a vertex boundary rather than between a vertex's two doubles.
220+
bool contiguous() const {
221+
return xy != nullptr && stride == 2 && xy_base % 2 == 0;
222+
}
223+
224+
operations::CoordSpan run(int64_t begin, int64_t end) {
225+
const std::size_t count = static_cast<std::size_t>(end - begin);
226+
if (contiguous()) {
227+
const auto *first =
228+
reinterpret_cast<const geo::Coord *>(xy + xy_base) + begin;
229+
return {first, count};
230+
}
231+
gathered.clear();
232+
gathered.reserve(count);
233+
for (int64_t v = begin; v < end; v++) {
234+
gathered.push_back(at(v));
235+
}
236+
return gathered;
237+
}
238+
209239
private:
210240
/// Doubles per vertex in an interleaved layout, read out of Arrow's
211241
/// fixed-size-list format string "+w:<width>": 2 for xy, 3 for xyz or
@@ -254,6 +284,8 @@ class Coordinates {
254284
const double *y = nullptr;
255285
int64_t x_base = 0;
256286
int64_t y_base = 0;
287+
/// where a layout that is not already a run of Coord is gathered into
288+
std::vector<geo::Coord> gathered;
257289
};
258290

259291
/// Check the GeoArrow extension name, when the producer declares one. A
@@ -324,15 +356,9 @@ struct LineStringReader {
324356

325357
int64_t size() const { return lines->length; }
326358

327-
/// Read linestring i into the given buffer
328-
void read(int64_t i, linestr &out) const {
329-
out.clear();
330-
int64_t begin = listBegin(lines, i);
331-
int64_t end = listEnd(lines, i);
332-
out.reserve(static_cast<std::size_t>(end - begin));
333-
for (int64_t v = begin; v < end; v++) {
334-
out.push_back(coordinates.at(v));
335-
}
359+
/// The vertices of linestring i
360+
operations::CoordSpan read(int64_t i) {
361+
return coordinates.run(listBegin(lines, i), listEnd(lines, i));
336362
}
337363
};
338364

@@ -356,21 +382,42 @@ struct PolygonReader {
356382

357383
int64_t size() const { return polygons->length; }
358384

359-
/// Read the rings of polygon i into the given buffer, exterior first.
360-
/// The ring structure is explicit in the offsets, so rings are recovered
361-
/// exactly rather than inferred from where coordinates close back on
362-
/// themselves.
363-
void read(int64_t i, std::vector<linestr> &out) const {
385+
/// The rings of polygon i, exterior first. The ring structure is explicit
386+
/// in the offsets, so rings are recovered exactly rather than inferred
387+
/// from where coordinates close back on themselves.
388+
///
389+
/// Only one ring's span is valid at a time unless the coordinates are
390+
/// contiguous, since a gathered ring reuses the same buffer - so a
391+
/// polygon whose coordinates need gathering is materialised ring by ring
392+
/// into `out` first, and spans taken over that.
393+
void read(int64_t i, std::vector<operations::CoordSpan> &out,
394+
std::vector<linestr> &scratch) {
364395
out.clear();
365-
for (int64_t r = listBegin(polygons, i); r < listEnd(polygons, i); r++) {
366-
linestr ring;
367-
int64_t begin = listBegin(rings, r);
368-
int64_t end = listEnd(rings, r);
396+
const int64_t first = listBegin(polygons, i);
397+
const int64_t last = listEnd(polygons, i);
398+
if (coordinates.contiguous()) {
399+
for (int64_t r = first; r < last; r++) {
400+
out.push_back(coordinates.run(listBegin(rings, r), listEnd(rings, r)));
401+
}
402+
return;
403+
}
404+
std::size_t used = 0;
405+
for (int64_t r = first; r < last; r++) {
406+
if (used == scratch.size()) {
407+
scratch.emplace_back();
408+
}
409+
// the ring buffers keep their capacity from the previous polygon
410+
linestr &ring = scratch[used++];
411+
ring.clear();
412+
const int64_t begin = listBegin(rings, r);
413+
const int64_t end = listEnd(rings, r);
369414
ring.reserve(static_cast<std::size_t>(end - begin));
370415
for (int64_t v = begin; v < end; v++) {
371416
ring.push_back(coordinates.at(v));
372417
}
373-
out.push_back(std::move(ring));
418+
}
419+
for (std::size_t r = 0; r < used; r++) {
420+
out.push_back(scratch[r]);
374421
}
375422
}
376423
};
@@ -576,17 +623,33 @@ class InputStream {
576623
/// and for each piece the index of the geometry it was split from.
577624
struct BatchData {
578625
GeometryType type = GeometryType::linestring;
579-
std::vector<geo::Coord> coordinates;
580-
/// where each run of coordinates begins: one run per piece for
581-
/// linestrings, one per ring for polygons. Offsets are 32-bit, which is
582-
/// what a plain Arrow list takes, and are relative to this batch.
583-
std::vector<int32_t> vertex_offsets{0};
584-
/// where each polygon's run of rings begins; unused for linestrings
585-
std::vector<int32_t> ring_offsets{0};
626+
/// The split kernels append straight into these, one geometry after
627+
/// another, so a batch's buffers are the ones the kernel filled. Nothing
628+
/// is concatenated on the way out, and no offset is rebased: the kernels
629+
/// record where a piece starts in the buffer they are filling, which for
630+
/// a whole batch is already the offset Arrow wants. Only the one matching
631+
/// `type` is used.
632+
operations::LinePieces lines;
633+
operations::PolygonPieces polygons;
586634
/// the geometry each piece came from, indexed across the whole stream
587635
std::vector<int64_t> parents;
588636

589637
int64_t size() const { return static_cast<int64_t>(parents.size()); }
638+
639+
std::vector<geo::Coord> &coordinates() {
640+
return type == GeometryType::polygon ? polygons.coordinates
641+
: lines.coordinates;
642+
}
643+
644+
/// Where each run of coordinates begins: one run per piece for
645+
/// linestrings, one per ring for polygons.
646+
std::vector<int32_t> &vertexOffsets() {
647+
return type == GeometryType::polygon ? polygons.ring_offsets
648+
: lines.offsets;
649+
}
650+
651+
/// Where each polygon's run of rings begins; unused for linestrings
652+
std::vector<int32_t> &ringOffsets() { return polygons.polygon_offsets; }
590653
};
591654

592655
/// Mark a schema and everything under it as holding no nulls
@@ -691,28 +754,28 @@ static void exportArray(BatchData data, ArrowArray *out) {
691754
ArrowArrayInitFromSchema(array.get(), schema.get(), nullptr));
692755

693756
const int64_t pieces = data.size();
694-
const int64_t n_vertices = static_cast<int64_t>(data.coordinates.size());
757+
const int64_t n_vertices = static_cast<int64_t>(data.coordinates().size());
695758

696759
ArrowArray *geometry = array->children[0];
697760
ArrowArray *vertices = geometry->children[0];
698761
if (data.type == GeometryType::polygon) {
699762
// polygons over rings, then rings over vertices
700763
ArrowArray *rings = vertices;
701764
vertices = rings->children[0];
702-
rings->length = static_cast<int64_t>(data.vertex_offsets.size()) - 1;
703-
adoptBuffer(rings, 1, std::move(data.vertex_offsets));
704-
adoptBuffer(geometry, 1, std::move(data.ring_offsets));
765+
rings->length = static_cast<int64_t>(data.vertexOffsets().size()) - 1;
766+
adoptBuffer(rings, 1, std::move(data.vertexOffsets()));
767+
adoptBuffer(geometry, 1, std::move(data.ringOffsets()));
705768
} else {
706769
// a linestring is a plain run of vertices, so one level of offsets does
707-
adoptBuffer(geometry, 1, std::move(data.vertex_offsets));
770+
adoptBuffer(geometry, 1, std::move(data.vertexOffsets()));
708771
}
709772
geometry->length = pieces;
710773
vertices->length = n_vertices;
711774

712775
// the coordinates as bare doubles: two per vertex, hence the length
713776
ArrowArray *xy = vertices->children[0];
714777
xy->length = 2 * n_vertices;
715-
adoptBuffer(xy, 1, std::move(data.coordinates));
778+
adoptBuffer(xy, 1, std::move(data.coordinates()));
716779

717780
ArrowArray *parent = array->children[1];
718781
parent->length = pieces;
@@ -748,50 +811,30 @@ struct SplitState {
748811
};
749812

750813
/// Split one batch of linestrings into pieces
751-
static void splitLineStringBatch(const LineStringReader &reader,
814+
static void splitLineStringBatch(LineStringReader &reader,
752815
const grid::Grid &grid, bool bounded,
753816
int64_t parent_base, BatchData &out) {
754-
linestr line;
755817
for (int64_t l = 0; l < reader.size(); l++) {
756-
reader.read(l, line);
757-
operations::LinePieces pieces =
758-
operations::splitLineStringGrid(line, grid, bounded);
759-
760-
std::size_t base = out.coordinates.size();
761-
out.coordinates.insert(out.coordinates.end(), pieces.coordinates.begin(),
762-
pieces.coordinates.end());
763-
for (std::size_t p = 1; p < pieces.offsets.size(); p++) {
764-
out.vertex_offsets.push_back(
765-
static_cast<int32_t>(base + pieces.offsets[p]));
766-
out.parents.push_back(parent_base + l);
767-
}
818+
const std::size_t before = out.lines.size();
819+
operations::splitLineStringGrid(reader.read(l), grid, bounded, out.lines);
820+
// one parent per piece this line produced; a line that fell apart into
821+
// nothing produces none
822+
out.parents.insert(out.parents.end(), out.lines.size() - before,
823+
parent_base + l);
768824
}
769825
}
770826

771827
/// Split one batch of polygons into pieces
772-
static void splitPolygonBatch(const PolygonReader &reader,
773-
const grid::Grid &grid, int64_t parent_base,
774-
BatchData &out) {
775-
std::vector<linestr> rings;
828+
static void splitPolygonBatch(PolygonReader &reader, const grid::Grid &grid,
829+
int64_t parent_base, BatchData &out) {
830+
std::vector<operations::CoordSpan> rings;
831+
std::vector<linestr> scratch;
776832
for (int64_t p = 0; p < reader.size(); p++) {
777-
reader.read(p, rings);
778-
operations::PolygonPieces pieces =
779-
operations::splitPolygonGridPieces(rings, grid);
780-
781-
// concatenate onto the batch, shifting the offsets
782-
std::size_t coordinate_base = out.coordinates.size();
783-
std::size_t ring_base = out.vertex_offsets.size() - 1;
784-
out.coordinates.insert(out.coordinates.end(), pieces.coordinates.begin(),
785-
pieces.coordinates.end());
786-
for (std::size_t r = 1; r < pieces.ring_offsets.size(); r++) {
787-
out.vertex_offsets.push_back(
788-
static_cast<int32_t>(coordinate_base + pieces.ring_offsets[r]));
789-
}
790-
for (std::size_t q = 1; q < pieces.polygon_offsets.size(); q++) {
791-
out.ring_offsets.push_back(
792-
static_cast<int32_t>(ring_base + pieces.polygon_offsets[q]));
793-
out.parents.push_back(parent_base + p);
794-
}
833+
reader.read(p, rings, scratch);
834+
const std::size_t before = out.polygons.size();
835+
operations::splitPolygonGridPieces(rings, grid, out.polygons);
836+
out.parents.insert(out.parents.end(), out.polygons.size() - before,
837+
parent_base + p);
795838
}
796839
}
797840

@@ -828,7 +871,7 @@ static std::optional<BatchData> nextSplitBatch(SplitState *state) {
828871
}
829872
state->parent_base += count;
830873

831-
if (out.coordinates.size() >
874+
if (out.coordinates().size() >
832875
static_cast<std::size_t>(std::numeric_limits<int32_t>::max())) {
833876
throw std::overflow_error(
834877
"One batch split to more coordinates than a GeoArrow array with "

extension/src/intersections.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ geometryCoordinates(py::object geometry) {
3131
py::module_::import("shapely").attr("get_coordinates")(geometry));
3232
}
3333

34-
/// Copy an offset array out as the int64 numpy array shapely expects
35-
py::array_t<std::int64_t> offsetArray(const std::vector<std::size_t> &offsets) {
34+
/// Copy an offset array out as the int64 numpy array shapely expects.
35+
/// The pieces hold theirs as int32, the width an Arrow list takes.
36+
py::array_t<std::int64_t> offsetArray(const std::vector<std::int32_t> &offsets) {
3637
py::array_t<std::int64_t> array((py::ssize_t)offsets.size());
3738
std::int64_t *out = array.mutable_data();
3839
for (std::size_t i = 0; i < offsets.size(); i++) {

0 commit comments

Comments
 (0)