Skip to content

Commit a03a5ba

Browse files
committed
fix(dataset_profiler): honor conversion and memory budgets
Use HDF5 numeric INT8 conversion for unsigned dense input, bound sparse offset and nnz retained state, and store duplicate digests in fixed-width arrays. Forward unavailability to AI payloads and add regression coverage. Signed-off-by: DoubleCore <DoubleCore@users.noreply.github.com> Assisted-by: OpenCode:ark-code-latest
1 parent 84f2763 commit a03a5ba

9 files changed

Lines changed: 267 additions & 86 deletions

File tree

‎docs/docs/en/src/resources/dataset_profiler.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,23 @@ The tool reads the layouts defined in [HDF5 Dataset Format](dataset_format.md):
6161
`uint32 len | uint32 ids[len] | float32 vals[len]` records, optionally indexed by
6262
`/<split>_offsets`. Sparse dimensionality is inferred as `max(feature id) + 1`.
6363

64-
For dense one-byte unsigned HDF5 integers, the profiler reinterprets each byte as signed `INT8`
65-
(128..255 becomes -128..-1), matching `eval_performance`'s `H5::PredType::ALPHA_I8` reads. The
66-
report dtype and all numeric statistics therefore use the same values as the evaluator.
64+
For dense one-byte unsigned HDF5 integers, the profiler uses HDF5's numeric conversion to signed
65+
`INT8` (values above 127 saturate at 127), matching `eval_performance`'s `H5::PredType::ALPHA_I8`
66+
reads. The report dtype and all numeric statistics therefore use the same values as the evaluator.
6767

6868
`multi_vector` files are rejected with an explanatory error. Data is streamed in bounded
6969
chunks: the profiler never materializes a complete dataset, and pairwise work is capped by
7070
the configured sample size, so no `O(N²)` matrix is ever allocated over the full dataset.
7171
Dense block sizes are derived from a working-memory budget rather than a fixed row count,
7272
sparse per-dimension accumulators stop at 10,000,000 inferred dimensions (the other metrics
7373
are still reported), and Euclidean distances switch to direct coordinate differences when
74-
the fast expanded form would cancel catastrophically. Full norm storage (8 bytes per vector)
75-
and exact-duplicate hash storage (estimated at 96 bytes per vector) each have a 256 MiB budget.
76-
If the row-count estimate exceeds either budget, the corresponding full-split norms/outliers or
77-
exact-duplicate fields are explicitly marked unavailable with a reason; no partial result is
78-
presented as full-dataset coverage, and streaming/sampled metrics are still reported.
74+
the fast expanded form would cancel catastrophically. Full norm and sparse nnz storage (8 bytes
75+
per vector each) and fixed-width exact-duplicate digests (16 bytes per vector) each have a 256 MiB
76+
retained-state budget. If the row-count estimate exceeds a budget, the corresponding full-split
77+
norms/outliers, nnz distribution, or exact-duplicate fields are explicitly marked unavailable with
78+
a reason; no partial result is presented as full-dataset coverage, and streaming/sampled metrics
79+
are still reported. Sparse offsets are validated in bounded windows; an index over 64 MiB is not
80+
retained and the reader falls back to bounded sequential decoding.
7981

8082
To profile arrays you only have in memory — for example the same arrays you pass to
8183
`pyvsag` — first export them with h5py to the layout above; see

‎docs/docs/zh/src/resources/dataset_profiler.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,18 @@ python -m dataset_profiler my_dataset.hdf5 \
5656
`uint32 len | uint32 ids[len] | float32 vals[len]`,可选地由 `/<split>_offsets` 索引。稀疏
5757
维度按 `max(特征 id) + 1` 推断。
5858

59-
对于稠密的一字节无符号 HDF5 整数,分析工具会逐字节重解释为有符号 `INT8`(128..255 映射为
60-
-128..-1),与 `eval_performance` 使用 `H5::PredType::ALPHA_I8` 读取的行为一致。因此,报告中的
59+
对于稠密的一字节无符号 HDF5 整数,分析工具通过 HDF5 数值转换读取为有符号 `INT8`(大于 127
60+
的值饱和为 127),与 `eval_performance` 使用 `H5::PredType::ALPHA_I8` 读取的行为一致。因此,报告中的
6161
dtype 与所有数值统计均使用 evaluator 实际看到的值。
6262

6363
`multi_vector` 文件会被拒绝并给出说明。数据按有界分块流式读取:工具不会把完整数据集载入内存,
6464
两两距离计算受采样规模约束,绝不会为完整数据集分配 `O(N²)` 矩阵。稠密数据的分块行数由工作内存
6565
预算推导而非固定行数;稀疏每维累加器在推断维度超过 10,000,000 时停止(其余指标仍会输出);
66-
当快速展开式计算会出现灾难性抵消时,欧氏距离改用坐标直接相减。全量范数存储(每向量 8 字节)
67-
与精确重复哈希存储(按每向量 96 字节估算)各有 256 MiB 预算。如果按行数估算超过任一预算,
68-
对应的全量范数/异常值或精确重复字段会明确标记为不可用并给出原因,不会把部分结果伪装成全量
69-
统计;其余流式与采样指标仍会输出。
66+
当快速展开式计算会出现灾难性抵消时,欧氏距离改用坐标直接相减。全量范数与稀疏 nnz 存储
67+
(各每向量 8 字节)以及定长精确重复摘要(每向量 16 字节)各有 256 MiB 保留状态预算。
68+
如果按行数估算超过任一预算,对应的全量范数/异常值、nnz 分布或精确重复字段会明确标记为不可用
69+
并给出原因,不会把部分结果伪装成全量统计;其余流式与采样指标仍会输出。稀疏 offsets 采用有界
70+
窗口校验;超过 64 MiB 的索引不会保留在内存中,读取器会回退到有界顺序解码。
7071

7172
如果数据只存在于内存中(例如准备传给 `pyvsag` 的数组),可先用 h5py 按上述布局导出为 HDF5,
7273
参见 `tools/dataset_profiler/examples/README.md`。

‎tools/dataset_profiler/TECHNICAL_REPORT.md‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,14 @@ dataset, and bounded memory for streaming sparse files.
5151
Full-dataset statistics (norms, per-dimension aggregates, duplicates,
5252
invalid counts, sparsity) come from a single streaming pass. Norm state is
5353
preallocated once (8 bytes per vector), never accumulated as block lists and
54-
concatenated. Norm state and exact-duplicate hash state (estimated at 96
55-
bytes per vector for the digest object and `Counter` entry) each have an
54+
concatenated. Norm state, sparse nnz state (8 bytes per vector each), and
55+
exact-duplicate digests (a preallocated 16-byte row per vector) each have an
5656
explicit 256 MiB retained-state budget. The row-count estimate is checked
5757
before allocation; when it exceeds the budget, the affected full-split
58-
norms/outliers or exact-duplicate fields are marked `available: false` with
59-
a reason and warning, while streaming and sampled metrics still appear.
58+
norms/outliers, nnz distribution, or exact-duplicate fields are marked
59+
unavailable with a reason and warning, while streaming totals and sampled
60+
metrics still appear. Duplicate groups are counted with NumPy over the
61+
fixed-width digest rows rather than a Python `Counter`.
6062
* **Distance computations**: Euclidean pairwise distances use the expanded
6163
norm/dot form when it is numerically stable and fall back to direct
6264
coordinate differences for pairs whose norms dwarf their distance (for
@@ -77,8 +79,11 @@ dataset, and bounded memory for streaming sparse files.
7779
convention (BLAS-dependent), which only affects projection scatter
7880
orientation, not the scalar anisotropy statistics.
7981
* **Sparse offsets**: results must not change between the `*_offsets`-indexed
80-
and scan-only encodings of the same byte stream. Tests assert full-dataset
81-
metric equality between the two paths.
82+
and scan-only encodings of the same byte stream. Offset validation reads
83+
bounded windows. An index larger than 64 MiB is not retained; the reader
84+
uses the bounded sequential decoder and cross-checks its decoded record
85+
count. Tests assert full-dataset metric equality between the paths and
86+
exercise the oversized-index fallback.
8287
* **Invalid values**: NaN/Inf entries are excluded from numeric summaries and
8388
treated as zero in norms, pairwise math, and PCA input (documented per
8489
metric). An all-zero vector is a *valid* vector: it contributes to counts,
@@ -153,12 +158,13 @@ message: deterministic reports are still produced and a warning is printed
153158
* Near-duplicate detection is a bounded *indicator* over the sample, not a
154159
full-dataset guarantee.
155160
* The duplicate hash includes invalid bytes as-is (byte equality, not numeric
156-
equality). Dense one-byte unsigned input is viewed as signed `INT8` before
157-
hashing and numeric statistics, matching the evaluator's `ALPHA_I8` reads;
158-
this preserves the bytes but maps 128..255 to -128..-1.
159-
* Full norm quantiles/outliers and exact duplicate counts are unavailable when
160-
their estimated per-row state exceeds the 256 MiB per-structure budget; the
161-
report marks these fields unavailable rather than returning partial results.
161+
equality). Dense one-byte unsigned input is numerically converted by HDF5 to
162+
signed `INT8` before hashing and numeric statistics, matching the evaluator's
163+
`ALPHA_I8` reads; values above 127 saturate at 127 rather than wrapping.
164+
* Full norm quantiles/outliers, sparse nnz distributions, and exact duplicate
165+
counts are unavailable when their estimated per-row state exceeds the 256 MiB
166+
per-structure budget; the report marks these fields unavailable rather than
167+
returning partial results.
162168
* k-means is an O(n·k) Lloyd loop over the 2-D projection; it is intended for
163169
structure hints, not production clustering.
164170
* Sparse dimensionality is inferred as `max(id) + 1`; a file with id gaps at

‎tools/dataset_profiler/dataset_profiler/ai.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
"definition",
7878
"scope",
7979
"sample_size",
80+
"available",
81+
"reason",
8082
"count",
8183
"stats",
8284
"histogram",
@@ -86,6 +88,8 @@
8688
"definition",
8789
"scope",
8890
"sample_size",
91+
"available",
92+
"reason",
8993
"count",
9094
"outlier_count",
9195
"outlier_ratio",
@@ -123,6 +127,8 @@
123127
"density",
124128
"nnz_per_vector",
125129
"nnz_histogram",
130+
"nnz_available",
131+
"nnz_reason",
126132
"all_zero_vectors",
127133
"all_zero_vector_ratio",
128134
),

‎tools/dataset_profiler/dataset_profiler/hdf5.py‎

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
1919
* **dense** (``type`` missing or ``"dense"``): ``/<split>`` is an ``(N, D)``
2020
matrix of ``INT8`` or ``FLOAT32`` values. One-byte unsigned integers are
21-
reinterpreted as ``INT8`` to match the evaluator's ``ALPHA_I8`` reads.
21+
numerically converted to ``INT8`` by HDF5 to match the evaluator's
22+
``ALPHA_I8`` reads (out-of-range values saturate at 127).
2223
* **sparse** (``type="sparse"``): ``/<split>`` is a flat ``INT8`` byte stream.
2324
Each record is ``uint32 len | uint32 ids[len] | float32 vals[len]`` in
2425
little-endian order. ``/<split>_offsets`` is an optional ``UINT64`` array of
@@ -38,6 +39,8 @@
3839
import numpy as np
3940

4041
DEFAULT_CHUNK_BYTES = 64 * 1024 * 1024
42+
_OFFSET_INDEX_BUDGET_BYTES = DEFAULT_CHUNK_BYTES
43+
_OFFSET_VALIDATION_ROWS = 1024 * 1024
4144
_LEN_STRUCT = struct.Struct("<I")
4245
_LEN_SIZE = _LEN_STRUCT.size
4346

@@ -175,27 +178,21 @@ def _read_metadata(self) -> DatasetMetadata:
175178
)
176179
offsets_name = self.split + "_offsets"
177180
if offsets_name in f:
178-
offsets = np.asarray(f[offsets_name][()], dtype=np.uint64)
179-
if len(offsets) < 1:
180-
raise DatasetFormatError(
181-
"'/%s' must contain at least the trailing sentinel" % offsets_name
182-
)
183-
if int(offsets[0]) != 0:
184-
raise DatasetFormatError(
185-
"'/%s' must start at byte offset 0" % offsets_name
186-
)
187-
if np.any(offsets[1:] < offsets[:-1]):
188-
raise DatasetFormatError(
189-
"'/%s' must be non-decreasing" % offsets_name
190-
)
191-
if int(offsets[-1]) != int(dset.shape[0]):
192-
raise DatasetFormatError(
193-
"'/%s' sentinel (%d) does not match '/%s' size (%d)"
194-
% (offsets_name, int(offsets[-1]), self.split, int(dset.shape[0]))
195-
)
196-
count = len(offsets) - 1
181+
offsets_dset = f[offsets_name]
182+
offset_count = _validate_offsets(
183+
offsets_dset, int(dset.shape[0]), offsets_name
184+
)
185+
count = offset_count - 1
197186
has_offsets = True
198-
self._offsets = offsets
187+
offset_bytes = offset_count * np.dtype(np.uint64).itemsize
188+
if offset_bytes <= _OFFSET_INDEX_BUDGET_BYTES:
189+
self._offsets = np.asarray(offsets_dset[()], dtype=np.uint64)
190+
else:
191+
warnings.append(
192+
"sparse '/%s' exceeds the %d-byte retained-index budget; "
193+
"using the bounded sequential decoder"
194+
% (offsets_name, _OFFSET_INDEX_BUDGET_BYTES)
195+
)
199196
else:
200197
warnings.append(
201198
"sparse dataset has no '/%s_offsets'; records are counted by scanning"
@@ -238,7 +235,7 @@ def iter_dense_blocks(
238235
total = int(dset.shape[0])
239236
for start in range(0, total, block_rows):
240237
end = min(start + block_rows, total)
241-
yield start, _evaluator_dense_view(dset[start:end])
238+
yield start, _read_evaluator_dense(dset, slice(start, end))
242239

243240
def read_dense_rows(self, indices: Sequence[int]) -> np.ndarray:
244241
"""Read selected rows from a dense matrix (indices must be sorted)."""
@@ -250,7 +247,7 @@ def read_dense_rows(self, indices: Sequence[int]) -> np.ndarray:
250247
if np.any(idx[1:] <= idx[:-1]):
251248
raise ValueError("dense row indices must be strictly increasing")
252249
dset = self._file[self.split]
253-
return _evaluator_dense_view(dset[idx.tolist()])
250+
return _read_evaluator_dense(dset, idx.tolist())
254251

255252
# -- sparse --------------------------------------------------------
256253
def iter_sparse_records(
@@ -264,7 +261,13 @@ def iter_sparse_records(
264261
if self._offsets is not None:
265262
yield from self._iter_sparse_with_offsets(max_chunk_bytes)
266263
else:
267-
yield from self._iter_sparse_without_offsets(max_chunk_bytes)
264+
# With no index, or with an index too large to retain, decode the
265+
# byte stream directly. An oversized index still supplies the
266+
# record count, which is cross-checked by the decoder.
267+
yield from self._iter_sparse_without_offsets(
268+
max_chunk_bytes,
269+
expected_count=self.metadata.count if self.metadata.has_offsets else None,
270+
)
268271

269272
def _iter_sparse_with_offsets(
270273
self, max_chunk_bytes: int
@@ -297,7 +300,7 @@ def _iter_sparse_with_offsets(
297300
start = end
298301

299302
def _iter_sparse_without_offsets(
300-
self, max_chunk_bytes: int
303+
self, max_chunk_bytes: int, expected_count: Optional[int] = None
301304
) -> Iterator[Tuple[int, np.ndarray, np.ndarray]]:
302305
dset = self._file[self.split]
303306
total_bytes = int(dset.shape[0])
@@ -334,6 +337,11 @@ def _iter_sparse_without_offsets(
334337
"sparse byte stream has %d trailing byte(s) that do not form a "
335338
"complete record" % len(buffer)
336339
)
340+
if expected_count is not None and index != expected_count:
341+
raise DatasetFormatError(
342+
"sparse decoded record count (%d) does not match '/%s_offsets' (%d)"
343+
% (index, self.split, expected_count)
344+
)
337345

338346
def read_sparse_rows(
339347
self, indices: Sequence[int], max_chunk_bytes: int = DEFAULT_CHUNK_BYTES
@@ -357,12 +365,43 @@ def read_sparse_rows(
357365
return out
358366

359367

360-
def _evaluator_dense_view(values) -> np.ndarray:
361-
"""Match the evaluator's signed interpretation of one-byte dense values."""
362-
array = np.asarray(values)
363-
if array.dtype.kind == "u" and array.dtype.itemsize == 1:
364-
return array.view(np.int8)
365-
return array
368+
def _read_evaluator_dense(dset, selection) -> np.ndarray:
369+
"""Read dense values with the evaluator's HDF5 destination datatype."""
370+
dtype = np.dtype(dset.dtype)
371+
if dtype.kind == "u" and dtype.itemsize == 1:
372+
# ALPHA_I8 is a signed destination type. HDF5 performs a numeric
373+
# conversion and saturates uint8 values above 127; viewing the bytes
374+
# would instead wrap them to negative values.
375+
return np.asarray(dset.astype(np.int8)[selection])
376+
return np.asarray(dset[selection])
377+
378+
379+
def _validate_offsets(dset, total_bytes: int, name: str) -> int:
380+
"""Validate an on-disk offset index using bounded read windows."""
381+
if len(dset.shape) != 1:
382+
raise DatasetFormatError("'/%s' must be a 1-D offset array" % name)
383+
count = int(dset.shape[0])
384+
if count < 1:
385+
raise DatasetFormatError(
386+
"'/%s' must contain at least the trailing sentinel" % name
387+
)
388+
first = int(np.asarray(dset[0], dtype=np.uint64))
389+
last = int(np.asarray(dset[count - 1], dtype=np.uint64))
390+
if first != 0:
391+
raise DatasetFormatError("'/%s' must start at byte offset 0" % name)
392+
if last != int(total_bytes):
393+
raise DatasetFormatError(
394+
"'/%s' sentinel (%d) does not match the sparse byte stream size (%d)"
395+
% (name, last, int(total_bytes))
396+
)
397+
previous = first
398+
for start in range(1, count, _OFFSET_VALIDATION_ROWS):
399+
end = min(start + _OFFSET_VALIDATION_ROWS, count)
400+
window = np.asarray(dset[start:end], dtype=np.uint64)
401+
if int(window[0]) < previous or np.any(window[1:] < window[:-1]):
402+
raise DatasetFormatError("'/%s' must be non-decreasing" % name)
403+
previous = int(window[-1])
404+
return count
366405

367406

368407
def _describe_dtype(dtype: np.dtype) -> str:

0 commit comments

Comments
 (0)