Skip to content

Commit a4cbf15

Browse files
committed
feat(frontend): skip Arrow-to-object materialization via columnar engine
Avoid converting Arrow Tables into DataRecord[] arrays (~84 ms at 200K rows) by reading column values directly through a new ColumnarDataSource interface. Controlled by FEATURE_FLAGS.arrowColumnar (on by default).
1 parent c158cc1 commit a4cbf15

6 files changed

Lines changed: 319 additions & 78 deletions

File tree

streamlit_pivot/frontend/src/PivotRoot.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ import {
4242
} from "./engine/types";
4343
import { PivotData, type PivotDataOptions } from "./engine/PivotData";
4444
import {
45+
createArrowDataSource,
4546
parseArrowToRecords,
4647
getArrowColumnNames,
4748
getNumericColumns,
4849
} from "./engine/parseArrow";
4950
import {
51+
FEATURE_FLAGS,
5052
measureSync,
5153
logMetrics,
5254
checkBudgets,
@@ -131,9 +133,20 @@ const PivotRoot: FC<PivotRootProps> = ({
131133
[currentConfig],
132134
);
133135

134-
const { records, parseMs } = useMemo(() => {
136+
const { pivotInput, parseMs } = useMemo(() => {
137+
if (FEATURE_FLAGS.arrowColumnar) {
138+
const measured = measureSync(() => createArrowDataSource(dataframe));
139+
const ds = measured.result;
140+
return {
141+
pivotInput: ds && ds.numRows > 0 ? ds : null,
142+
parseMs: measured.elapsedMs,
143+
};
144+
}
135145
const measured = measureSync(() => parseArrowToRecords(dataframe));
136-
return { records: measured.result, parseMs: measured.elapsedMs };
146+
return {
147+
pivotInput: measured.result.length > 0 ? measured.result : null,
148+
parseMs: measured.elapsedMs,
149+
};
137150
}, [dataframe]);
138151

139152
const rawAllColumns = useMemo(
@@ -185,12 +198,12 @@ const PivotRoot: FC<PivotRootProps> = ({
185198
);
186199

187200
const { pivotData, computeMs } = useMemo(() => {
188-
if (records.length === 0) return { pivotData: null, computeMs: 0 };
201+
if (!pivotInput) return { pivotData: null, computeMs: 0 };
189202
const measured = measureSync(
190-
() => new PivotData(records, currentConfig, pivotOptions),
203+
() => new PivotData(pivotInput, currentConfig, pivotOptions),
191204
);
192205
return { pivotData: measured.result, computeMs: measured.elapsedMs };
193-
}, [records, currentConfig, pivotOptions]);
206+
}, [pivotInput, currentConfig, pivotOptions]);
194207

195208
const budget = useMemo(() => {
196209
if (!pivotData) return null;
@@ -268,7 +281,7 @@ const PivotRoot: FC<PivotRootProps> = ({
268281
firstMountMs:
269282
debugMetrics?.firstMountMs ??
270283
Math.round((parseMs + computeMs + renderMs) * 100) / 100,
271-
sourceRows: records.length,
284+
sourceRows: pivotData.recordCount,
272285
sourceCols: rawAllColumns.length,
273286
totalRows: pivotData.uniqueRowKeyCount,
274287
totalCols: pivotData.uniqueColKeyCount,
@@ -304,7 +317,7 @@ const PivotRoot: FC<PivotRootProps> = ({
304317
parseMs,
305318
pivotData,
306319
rawAllColumns.length,
307-
records.length,
320+
pivotInput,
308321
]);
309322

310323
// perf_metrics are exposed via the data-perf-metrics DOM attribute (set

streamlit_pivot/frontend/src/engine/PivotData.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import {
2727
normalizeAggregationConfig,
2828
type AggregationConfig,
2929
type AggregationType,
30+
type ColumnarDataSource,
3031
type PivotConfigV1,
3132
} from "./types";
33+
import { DataRecordSource } from "./parseArrow";
3234
import { measureSync, DEFAULT_BUDGETS } from "./perf";
3335

3436
type TestConfigOverrides = Partial<Omit<PivotConfigV1, "aggregation">> & {
@@ -1648,3 +1650,79 @@ describe("PivotData - getColumnNames", () => {
16481650
expect(cols).toHaveLength(4);
16491651
});
16501652
});
1653+
1654+
describe("PivotData - DataRecordSource wrapper", () => {
1655+
it("matches array-backed aggregates and keys", () => {
1656+
const cfg = makeConfig();
1657+
const fromArray = new PivotData(SAMPLE_DATA, cfg);
1658+
const source = new DataRecordSource(
1659+
SAMPLE_DATA,
1660+
Object.keys(SAMPLE_DATA[0]!),
1661+
);
1662+
const fromSource = new PivotData(source, cfg);
1663+
expect(fromSource.getRowKeys()).toEqual(fromArray.getRowKeys());
1664+
expect(fromSource.getColKeys()).toEqual(fromArray.getColKeys());
1665+
expect(fromSource.getAggregator(["US"], ["2023"]).value()).toEqual(
1666+
fromArray.getAggregator(["US"], ["2023"]).value(),
1667+
);
1668+
});
1669+
});
1670+
1671+
class TestColumnarSource implements ColumnarDataSource {
1672+
constructor(private readonly rows: DataRecord[]) {}
1673+
1674+
get numRows(): number {
1675+
return this.rows.length;
1676+
}
1677+
1678+
getValue(rowIndex: number, fieldName: string): unknown {
1679+
return this.rows[rowIndex]![fieldName];
1680+
}
1681+
1682+
getColumnNames(): string[] {
1683+
return Object.keys(this.rows[0] ?? {});
1684+
}
1685+
}
1686+
1687+
describe("PivotData - non-materialized columnar source", () => {
1688+
it("getMatchingRecords materializes rows on demand", () => {
1689+
const cfg = makeConfig();
1690+
const src = new TestColumnarSource([...SAMPLE_DATA]);
1691+
const pd = new PivotData(src, cfg);
1692+
const r = pd.getMatchingRecords({ region: "US", year: "2023" });
1693+
expect(r.totalCount).toBe(2);
1694+
expect(r.records).toHaveLength(2);
1695+
expect(
1696+
r.records.every((row) => row.region === "US" && row.year === "2023"),
1697+
).toBe(true);
1698+
});
1699+
1700+
it("getUniqueValues scans the columnar source", () => {
1701+
const src = new TestColumnarSource(SAMPLE_DATA);
1702+
const pd = new PivotData(src, makeConfig());
1703+
expect(pd.getUniqueValues("region")).toEqual(["EU", "US"]);
1704+
});
1705+
1706+
it("aggregates match array-backed results", () => {
1707+
const cfg = makeConfig();
1708+
const fromArray = new PivotData(SAMPLE_DATA, cfg);
1709+
const fromColumnar = new PivotData(
1710+
new TestColumnarSource(SAMPLE_DATA),
1711+
cfg,
1712+
);
1713+
expect(fromColumnar.getRowKeys()).toEqual(fromArray.getRowKeys());
1714+
expect(fromColumnar.getColKeys()).toEqual(fromArray.getColKeys());
1715+
expect(fromColumnar.getGrandTotal().value()).toEqual(
1716+
fromArray.getGrandTotal().value(),
1717+
);
1718+
expect(fromColumnar.recordCount).toBe(fromArray.recordCount);
1719+
});
1720+
1721+
it("getColumnNames works without materialized records", () => {
1722+
const src = new TestColumnarSource(SAMPLE_DATA);
1723+
const pd = new PivotData(src, makeConfig());
1724+
expect(pd.getColumnNames()).toEqual(
1725+
expect.arrayContaining(["region", "year", "revenue", "profit"]),
1726+
);
1727+
});
1728+
});

0 commit comments

Comments
 (0)