fastsimdjson 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastsimdjson-0.3.0/src/fastsimdjson.egg-info → fastsimdjson-0.5.0}/PKG-INFO +61 -6
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/README.md +60 -5
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/pyproject.toml +1 -1
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.cpp +474 -13
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0/src/fastsimdjson.egg-info}/PKG-INFO +61 -6
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_dumps.py +20 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_stream.py +66 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/MANIFEST.in +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/setup.cfg +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/setup.py +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/SOURCES.txt +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/dependency_links.txt +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/requires.txt +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/top_level.txt +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_files.py +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_lazy.py +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_loads.py +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdjson.cpp +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdjson.h +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf.cpp +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf.h +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf_c.h +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij-LICENSE +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij.cc +0 -0
- {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij.h +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastsimdjson
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Fast JSON parsing for Python, built on simdjson
|
|
5
5
|
Author-email: Daniel Lemire <daniel@lemire.me>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -197,6 +197,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
|
|
|
197
197
|
position in the whole input. A truncated last document is an error. The
|
|
198
198
|
views returned by `parse_many` remain valid after the iterator moves on.
|
|
199
199
|
|
|
200
|
+
### Streams on several threads: `threads=N` and `map_many`
|
|
201
|
+
|
|
202
|
+
On free-threaded Python (3.14t and newer), a stream can be parsed by several
|
|
203
|
+
threads at once:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
|
|
207
|
+
views = fastsimdjson.parse_many(data, threads=16) # a list of views
|
|
208
|
+
# fn runs in the threads too, on each document; its results, in order:
|
|
209
|
+
scores = fastsimdjson.map_many(score, data, threads=16)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With `threads`, `loads_many` and `parse_many` return a list of all the
|
|
213
|
+
documents instead of an iterator. `map_many(fn, data, **kw)` returns
|
|
214
|
+
`[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
|
|
215
|
+
`lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
|
|
216
|
+
one thread per processor; small inputs use fewer threads (about one per
|
|
217
|
+
512 KB). The input is cut into chunks at newlines (before each `\x1e` for
|
|
218
|
+
`json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
|
|
219
|
+
and `"json_seq"`.
|
|
220
|
+
|
|
221
|
+
The results, and the exceptions, are those of one thread: when a chunk
|
|
222
|
+
contains an error or a document that only `json` accepts, the documents
|
|
223
|
+
before it are kept and the rest is parsed in the calling thread, as without
|
|
224
|
+
threads. An exception raised by `fn` is raised again, for the first document
|
|
225
|
+
where it happened; `fn` may then have been called on later documents, in
|
|
226
|
+
other threads, and it must be safe to call from several threads at once.
|
|
227
|
+
|
|
228
|
+
`map_many` scales best: each document is built, used and freed in the same
|
|
229
|
+
thread, and only `fn`'s results come back. `loads_many` must return every
|
|
230
|
+
document, and the Python objects of all of them, to the calling thread.
|
|
231
|
+
With the GIL (the default build of Python), the calling thread does all the
|
|
232
|
+
work: `threads` has no effect on speed.
|
|
233
|
+
|
|
200
234
|
### `release`
|
|
201
235
|
|
|
202
236
|
`release()` frees the simdjson parser and the string caches kept by the
|
|
@@ -433,14 +467,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
|
|
|
433
467
|
faster). `parse_many` only creates the views; reading values from them adds to
|
|
434
468
|
its time.
|
|
435
469
|
|
|
470
|
+
### Streams on several threads
|
|
471
|
+
|
|
472
|
+
300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
|
|
473
|
+
gsoc and update-center entries), free-threaded Python 3.14.6, collector
|
|
474
|
+
disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
|
|
475
|
+
|
|
476
|
+
| threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|
|
477
|
+
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
478
|
+
| `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
|
|
479
|
+
| `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
|
|
480
|
+
| `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
|
|
481
|
+
|
|
482
|
+
`map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
|
|
483
|
+
every document for the calling thread and is 11 times faster. With the
|
|
484
|
+
collector enabled, Python collects after the call returns: it examines the
|
|
485
|
+
millions of objects just created, which takes about 0.1 s here, with or
|
|
486
|
+
without threads.
|
|
487
|
+
|
|
436
488
|
## Limitations
|
|
437
489
|
|
|
438
490
|
* The simdjson parser and the key and string caches are thread-local.
|
|
439
491
|
`release()` frees the parser and the cached strings retained by the calling
|
|
440
|
-
thread
|
|
441
|
-
|
|
442
|
-
its cached strings behind.
|
|
492
|
+
thread, and they are freed when a Python thread exits. A parser that grows
|
|
493
|
+
past 64 MB is freed on its own at the end of that call; its caches stay.
|
|
443
494
|
* The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
|
|
444
495
|
Python 3.13 and newer), so importing it on a free-threaded build does not
|
|
445
|
-
re-enable the GIL.
|
|
446
|
-
|
|
496
|
+
re-enable the GIL. It is tested with many threads parsing, serializing and
|
|
497
|
+
sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
|
|
498
|
+
On a free-threaded build, `bytearray` and `memoryview` inputs are copied
|
|
499
|
+
before parsing, and `dumps` and `dumpb` hold references to the items of the
|
|
500
|
+
containers they serialize, since other threads may change them.
|
|
501
|
+
Subinterpreters are not supported.
|
|
@@ -166,6 +166,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
|
|
|
166
166
|
position in the whole input. A truncated last document is an error. The
|
|
167
167
|
views returned by `parse_many` remain valid after the iterator moves on.
|
|
168
168
|
|
|
169
|
+
### Streams on several threads: `threads=N` and `map_many`
|
|
170
|
+
|
|
171
|
+
On free-threaded Python (3.14t and newer), a stream can be parsed by several
|
|
172
|
+
threads at once:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
|
|
176
|
+
views = fastsimdjson.parse_many(data, threads=16) # a list of views
|
|
177
|
+
# fn runs in the threads too, on each document; its results, in order:
|
|
178
|
+
scores = fastsimdjson.map_many(score, data, threads=16)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
With `threads`, `loads_many` and `parse_many` return a list of all the
|
|
182
|
+
documents instead of an iterator. `map_many(fn, data, **kw)` returns
|
|
183
|
+
`[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
|
|
184
|
+
`lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
|
|
185
|
+
one thread per processor; small inputs use fewer threads (about one per
|
|
186
|
+
512 KB). The input is cut into chunks at newlines (before each `\x1e` for
|
|
187
|
+
`json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
|
|
188
|
+
and `"json_seq"`.
|
|
189
|
+
|
|
190
|
+
The results, and the exceptions, are those of one thread: when a chunk
|
|
191
|
+
contains an error or a document that only `json` accepts, the documents
|
|
192
|
+
before it are kept and the rest is parsed in the calling thread, as without
|
|
193
|
+
threads. An exception raised by `fn` is raised again, for the first document
|
|
194
|
+
where it happened; `fn` may then have been called on later documents, in
|
|
195
|
+
other threads, and it must be safe to call from several threads at once.
|
|
196
|
+
|
|
197
|
+
`map_many` scales best: each document is built, used and freed in the same
|
|
198
|
+
thread, and only `fn`'s results come back. `loads_many` must return every
|
|
199
|
+
document, and the Python objects of all of them, to the calling thread.
|
|
200
|
+
With the GIL (the default build of Python), the calling thread does all the
|
|
201
|
+
work: `threads` has no effect on speed.
|
|
202
|
+
|
|
169
203
|
### `release`
|
|
170
204
|
|
|
171
205
|
`release()` frees the simdjson parser and the string caches kept by the
|
|
@@ -402,14 +436,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
|
|
|
402
436
|
faster). `parse_many` only creates the views; reading values from them adds to
|
|
403
437
|
its time.
|
|
404
438
|
|
|
439
|
+
### Streams on several threads
|
|
440
|
+
|
|
441
|
+
300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
|
|
442
|
+
gsoc and update-center entries), free-threaded Python 3.14.6, collector
|
|
443
|
+
disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
|
|
444
|
+
|
|
445
|
+
| threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|
|
446
|
+
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
447
|
+
| `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
|
|
448
|
+
| `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
|
|
449
|
+
| `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
|
|
450
|
+
|
|
451
|
+
`map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
|
|
452
|
+
every document for the calling thread and is 11 times faster. With the
|
|
453
|
+
collector enabled, Python collects after the call returns: it examines the
|
|
454
|
+
millions of objects just created, which takes about 0.1 s here, with or
|
|
455
|
+
without threads.
|
|
456
|
+
|
|
405
457
|
## Limitations
|
|
406
458
|
|
|
407
459
|
* The simdjson parser and the key and string caches are thread-local.
|
|
408
460
|
`release()` frees the parser and the cached strings retained by the calling
|
|
409
|
-
thread
|
|
410
|
-
|
|
411
|
-
its cached strings behind.
|
|
461
|
+
thread, and they are freed when a Python thread exits. A parser that grows
|
|
462
|
+
past 64 MB is freed on its own at the end of that call; its caches stay.
|
|
412
463
|
* The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
|
|
413
464
|
Python 3.13 and newer), so importing it on a free-threaded build does not
|
|
414
|
-
re-enable the GIL.
|
|
415
|
-
|
|
465
|
+
re-enable the GIL. It is tested with many threads parsing, serializing and
|
|
466
|
+
sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
|
|
467
|
+
On a free-threaded build, `bytearray` and `memoryview` inputs are copied
|
|
468
|
+
before parsing, and `dumps` and `dumpb` hold references to the items of the
|
|
469
|
+
containers they serialize, since other threads may change them.
|
|
470
|
+
Subinterpreters are not supported.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
#include <cstring>
|
|
12
12
|
#include <new>
|
|
13
13
|
#include <string>
|
|
14
|
+
#include <thread>
|
|
14
15
|
#include <vector>
|
|
15
16
|
#ifdef _WIN32
|
|
16
17
|
#ifndef NOMINMAX
|
|
@@ -147,6 +148,7 @@ struct ThreadParser {
|
|
|
147
148
|
KeyCacheEntry key_cache[KEY_CACHE_SIZE]{};
|
|
148
149
|
KeyCacheEntry value_cache[KEY_CACHE_SIZE]{};
|
|
149
150
|
EncodedKey *encoded_keys = nullptr; // allocated on first use by dumpb
|
|
151
|
+
bool cleanup_registered = false; // see register_thread_cleanup
|
|
150
152
|
void clear_encoded_keys() {
|
|
151
153
|
if (encoded_keys != nullptr) {
|
|
152
154
|
for (size_t i = 0; i < ENCODED_KEY_CACHE_SIZE; i++) {
|
|
@@ -170,6 +172,50 @@ struct ThreadParser {
|
|
|
170
172
|
};
|
|
171
173
|
thread_local ThreadParser g_thread_parser;
|
|
172
174
|
|
|
175
|
+
// Frees this thread's parser, documents and caches (release()).
|
|
176
|
+
void release_thread_state() {
|
|
177
|
+
delete g_thread_parser.ptr;
|
|
178
|
+
g_thread_parser.ptr = nullptr;
|
|
179
|
+
delete g_thread_parser.spare;
|
|
180
|
+
g_thread_parser.spare = nullptr;
|
|
181
|
+
clear_cache(g_thread_parser.key_cache);
|
|
182
|
+
clear_cache(g_thread_parser.value_cache);
|
|
183
|
+
g_thread_parser.clear_encoded_keys();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The thread-local destructor runs after Python has deleted the thread
|
|
187
|
+
// state of an exiting thread, too late to release the cached strings. So
|
|
188
|
+
// each thread puts a capsule in its thread state's dict: Python clears that
|
|
189
|
+
// dict while the thread state is still valid, and the capsule's destructor
|
|
190
|
+
// releases the caches then. It does so only on its own thread (a thread
|
|
191
|
+
// state can be cleared from another thread, at finalization).
|
|
192
|
+
const char THREAD_CAPSULE[] = "fastsimdjson.thread_state";
|
|
193
|
+
|
|
194
|
+
void thread_cleanup(PyObject *capsule) {
|
|
195
|
+
if (PyCapsule_GetPointer(capsule, THREAD_CAPSULE) == &g_thread_parser) {
|
|
196
|
+
g_thread_parser.cleanup_registered = false;
|
|
197
|
+
if (caches_can_decref()) {
|
|
198
|
+
release_thread_state();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
FSJ_NOINLINE void register_thread_cleanup_slow() {
|
|
204
|
+
g_thread_parser.cleanup_registered = true;
|
|
205
|
+
PyObject *dict = PyThreadState_GetDict(); // borrowed
|
|
206
|
+
PyObject *capsule = dict ? PyCapsule_New(&g_thread_parser, THREAD_CAPSULE, thread_cleanup) : nullptr;
|
|
207
|
+
if (capsule == nullptr || PyDict_SetItemString(dict, THREAD_CAPSULE, capsule) < 0) {
|
|
208
|
+
PyErr_Clear(); // without it, the caches of an exiting thread leak, as before
|
|
209
|
+
}
|
|
210
|
+
Py_XDECREF(capsule);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
inline void register_thread_cleanup() {
|
|
214
|
+
if (!g_thread_parser.cleanup_registered) {
|
|
215
|
+
register_thread_cleanup_slow();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
173
219
|
inline uint64_t load_u64(const char *p) {
|
|
174
220
|
uint64_t v;
|
|
175
221
|
memcpy(&v, p, 8);
|
|
@@ -284,6 +330,7 @@ inline PyObject *make_str(const char *s, size_t len) {
|
|
|
284
330
|
}
|
|
285
331
|
|
|
286
332
|
inline void cache_store(KeyCacheEntry &entry, PyObject *u, size_t len, const KeyWords &w) {
|
|
333
|
+
register_thread_cleanup(); // the entry holds a reference: free it at thread exit
|
|
287
334
|
Py_INCREF(u);
|
|
288
335
|
Py_XDECREF(entry.key);
|
|
289
336
|
entry.key = u;
|
|
@@ -593,6 +640,7 @@ bool ensure_parser() {
|
|
|
593
640
|
if (g_thread_parser.ptr != nullptr) {
|
|
594
641
|
return true;
|
|
595
642
|
}
|
|
643
|
+
register_thread_cleanup();
|
|
596
644
|
g_thread_parser.ptr = new (std::nothrow) parser();
|
|
597
645
|
if (g_thread_parser.ptr == nullptr) {
|
|
598
646
|
PyErr_NoMemory();
|
|
@@ -2698,6 +2746,7 @@ struct OrjsonEncoder {
|
|
|
2698
2746
|
return -1;
|
|
2699
2747
|
}
|
|
2700
2748
|
if (keys == nullptr) {
|
|
2749
|
+
register_thread_cleanup();
|
|
2701
2750
|
keys = g_thread_parser.encoded_keys = new (std::nothrow) EncodedKey[ENCODED_KEY_CACHE_SIZE];
|
|
2702
2751
|
}
|
|
2703
2752
|
int rc = 0;
|
|
@@ -3004,6 +3053,9 @@ struct StreamObject {
|
|
|
3004
3053
|
size_t open; // array format: offsets of the outer '[' and ']'
|
|
3005
3054
|
size_t close;
|
|
3006
3055
|
PyObject *text; // the input as str, built on the error path
|
|
3056
|
+
size_t text_len; // bytes of buf that `text` covers (len, or more for a chunk)
|
|
3057
|
+
bool no_fallback; // a chunk of a parallel run: do not call json (see run_chunk)
|
|
3058
|
+
bool need_fallback; // set instead of calling json, with no_fallback
|
|
3007
3059
|
parser *p;
|
|
3008
3060
|
document_stream *stream;
|
|
3009
3061
|
document_stream::iterator it;
|
|
@@ -3063,7 +3115,7 @@ PyObject *stream_text(StreamObject *s) {
|
|
|
3063
3115
|
if (s->text == nullptr) {
|
|
3064
3116
|
s->text = PyUnicode_Check(s->source)
|
|
3065
3117
|
? Py_NewRef(s->source)
|
|
3066
|
-
: PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->
|
|
3118
|
+
: PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->text_len), nullptr);
|
|
3067
3119
|
}
|
|
3068
3120
|
return s->text;
|
|
3069
3121
|
}
|
|
@@ -3204,6 +3256,16 @@ PyObject *stream_fallback(StreamObject *s) {
|
|
|
3204
3256
|
return value;
|
|
3205
3257
|
}
|
|
3206
3258
|
|
|
3259
|
+
// In a chunk of a parallel run, json is not called: the run continues in one
|
|
3260
|
+
// thread from there, where json decides as it does without threads.
|
|
3261
|
+
PyObject *stream_fallback_or_stop(StreamObject *s) {
|
|
3262
|
+
if (s->no_fallback) {
|
|
3263
|
+
s->need_fallback = true;
|
|
3264
|
+
return nullptr;
|
|
3265
|
+
}
|
|
3266
|
+
return stream_fallback(s);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3207
3269
|
// The next document, or nullptr at the end or after an error.
|
|
3208
3270
|
PyObject *stream_next_locked(StreamObject *s) {
|
|
3209
3271
|
for (;;) {
|
|
@@ -3218,7 +3280,7 @@ PyObject *stream_next_locked(StreamObject *s) {
|
|
|
3218
3280
|
if (!(s->it != s->stream->end())) {
|
|
3219
3281
|
// Anything but separators after the last document is an incomplete
|
|
3220
3282
|
// document: let json report it.
|
|
3221
|
-
return skip_separators(s, s->last_end) < s->len ?
|
|
3283
|
+
return skip_separators(s, s->last_end) < s->len ? stream_fallback_or_stop(s) : nullptr;
|
|
3222
3284
|
}
|
|
3223
3285
|
simdjson::dom::element el;
|
|
3224
3286
|
simdjson::error_code err = (*s->it).get(el);
|
|
@@ -3237,7 +3299,7 @@ PyObject *stream_next_locked(StreamObject *s) {
|
|
|
3237
3299
|
s->active = false;
|
|
3238
3300
|
continue;
|
|
3239
3301
|
}
|
|
3240
|
-
return
|
|
3302
|
+
return stream_fallback_or_stop(s);
|
|
3241
3303
|
}
|
|
3242
3304
|
}
|
|
3243
3305
|
|
|
@@ -3345,6 +3407,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
|
|
|
3345
3407
|
s->active = false;
|
|
3346
3408
|
s->base = 0;
|
|
3347
3409
|
s->last_end = 0;
|
|
3410
|
+
s->no_fallback = s->need_fallback = false;
|
|
3348
3411
|
PyObject *self = reinterpret_cast<PyObject *>(s);
|
|
3349
3412
|
if (must_copy) {
|
|
3350
3413
|
PyObject *b = PyBytes_FromObject(data);
|
|
@@ -3367,7 +3430,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
|
|
|
3367
3430
|
buf = s->copy->data();
|
|
3368
3431
|
}
|
|
3369
3432
|
s->buf = buf;
|
|
3370
|
-
s->len = size_t(len);
|
|
3433
|
+
s->len = s->text_len = size_t(len);
|
|
3371
3434
|
// parse_many skips a UTF-8 byte order mark and, for the array format,
|
|
3372
3435
|
// white space and the opening '['; the offsets it reports start after.
|
|
3373
3436
|
size_t i = s->len >= 3 && memcmp(buf, "\xEF\xBB\xBF", 3) == 0 ? 3 : 0;
|
|
@@ -3403,12 +3466,410 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
|
|
|
3403
3466
|
return self;
|
|
3404
3467
|
}
|
|
3405
3468
|
|
|
3469
|
+
// ---------------------------------------------------------------------------
|
|
3470
|
+
// Streams over several threads (threads=N): the input is cut into chunks at
|
|
3471
|
+
// document boundaries (after a newline; before a record separator for
|
|
3472
|
+
// json_seq), and threads take the chunks in turn, each building the
|
|
3473
|
+
// documents of its chunk (and calling fn on them, for map_many). Without
|
|
3474
|
+
// the GIL (free-threaded Python), the threads run in parallel; with it, the
|
|
3475
|
+
// calling thread does all the work.
|
|
3476
|
+
//
|
|
3477
|
+
// The result is that of one thread: at the first chunk where anything other
|
|
3478
|
+
// than plain documents shows up (an error, a document that only json
|
|
3479
|
+
// accepts), the documents before it are kept and the calling thread goes on
|
|
3480
|
+
// from there as a stream without threads would. (fn may then have been
|
|
3481
|
+
// called on documents after that point, in other threads.)
|
|
3482
|
+
// ---------------------------------------------------------------------------
|
|
3483
|
+
|
|
3484
|
+
// A stream over [from, to) of the input of `whole`, sharing its buffer.
|
|
3485
|
+
StreamObject *stream_over(StreamObject *whole, size_t from, size_t to) {
|
|
3486
|
+
StreamObject *s = PyObject_New(StreamObject, StreamType);
|
|
3487
|
+
if (s == nullptr) {
|
|
3488
|
+
return nullptr;
|
|
3489
|
+
}
|
|
3490
|
+
s->source = Py_NewRef(whole->source);
|
|
3491
|
+
s->copy = nullptr;
|
|
3492
|
+
s->text = nullptr;
|
|
3493
|
+
s->stream = nullptr;
|
|
3494
|
+
s->p = nullptr;
|
|
3495
|
+
new (&s->it) document_stream::iterator();
|
|
3496
|
+
s->buf = whole->buf;
|
|
3497
|
+
s->len = to;
|
|
3498
|
+
s->text_len = whole->len;
|
|
3499
|
+
s->batch_size = whole->batch_size;
|
|
3500
|
+
s->format = whole->format;
|
|
3501
|
+
s->lazy = whole->lazy;
|
|
3502
|
+
s->done = false;
|
|
3503
|
+
s->active = false;
|
|
3504
|
+
s->base = 0;
|
|
3505
|
+
s->last_end = from;
|
|
3506
|
+
s->prefix = whole->prefix;
|
|
3507
|
+
s->open = whole->open;
|
|
3508
|
+
s->close = whole->close;
|
|
3509
|
+
s->no_fallback = s->need_fallback = false;
|
|
3510
|
+
s->p = new (std::nothrow) parser();
|
|
3511
|
+
if (s->p == nullptr) {
|
|
3512
|
+
Py_DECREF(reinterpret_cast<PyObject *>(s));
|
|
3513
|
+
PyErr_NoMemory();
|
|
3514
|
+
return nullptr;
|
|
3515
|
+
}
|
|
3516
|
+
s->p->number_as_string(true);
|
|
3517
|
+
return s;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
struct ParallelRun {
|
|
3521
|
+
StreamObject *whole;
|
|
3522
|
+
PyObject *fn; // borrowed, or nullptr
|
|
3523
|
+
std::vector<size_t> bounds; // chunk i is [bounds[i], bounds[i + 1])
|
|
3524
|
+
struct Chunk {
|
|
3525
|
+
PyObject *items = nullptr; // list of the chunk's results
|
|
3526
|
+
bool failed = false; // stopped before the end of the chunk
|
|
3527
|
+
PyObject *exc = nullptr; // the exception fn raised, if it did
|
|
3528
|
+
size_t resume_at = 0; // where the one-thread run goes on
|
|
3529
|
+
};
|
|
3530
|
+
std::vector<Chunk> chunks;
|
|
3531
|
+
std::atomic<size_t> next{0};
|
|
3532
|
+
std::atomic<size_t> first_failure{SIZE_MAX};
|
|
3533
|
+
|
|
3534
|
+
void note_failure(size_t i) {
|
|
3535
|
+
size_t f = first_failure.load();
|
|
3536
|
+
while (i < f && !first_failure.compare_exchange_weak(f, i)) {
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// On an attached thread.
|
|
3541
|
+
void run_chunk(size_t i) {
|
|
3542
|
+
Chunk &c = chunks[i];
|
|
3543
|
+
c.resume_at = bounds[i];
|
|
3544
|
+
StreamObject *st = stream_over(whole, bounds[i], bounds[i + 1]);
|
|
3545
|
+
c.items = st ? PyList_New(0) : nullptr;
|
|
3546
|
+
if (c.items == nullptr) {
|
|
3547
|
+
PyErr_Clear(); // the one-thread run will report it
|
|
3548
|
+
c.failed = true;
|
|
3549
|
+
}
|
|
3550
|
+
if (st != nullptr && c.items != nullptr) {
|
|
3551
|
+
st->no_fallback = true;
|
|
3552
|
+
while (first_failure.load(std::memory_order_relaxed) > i) {
|
|
3553
|
+
size_t before = st->last_end;
|
|
3554
|
+
PyObject *v = stream_next_locked(st);
|
|
3555
|
+
if (v == nullptr) {
|
|
3556
|
+
if (PyErr_Occurred() || st->need_fallback) {
|
|
3557
|
+
PyErr_Clear(); // reported again by the one-thread run
|
|
3558
|
+
c.failed = true;
|
|
3559
|
+
c.resume_at = before;
|
|
3560
|
+
}
|
|
3561
|
+
break;
|
|
3562
|
+
}
|
|
3563
|
+
c.resume_at = st->last_end;
|
|
3564
|
+
if (fn != nullptr) {
|
|
3565
|
+
PyObject *r = PyObject_CallOneArg(fn, v);
|
|
3566
|
+
Py_DECREF(v);
|
|
3567
|
+
if (r == nullptr) {
|
|
3568
|
+
PyObject *t, *val, *tb;
|
|
3569
|
+
PyErr_Fetch(&t, &val, &tb);
|
|
3570
|
+
PyErr_NormalizeException(&t, &val, &tb);
|
|
3571
|
+
if (tb != nullptr && val != nullptr) {
|
|
3572
|
+
PyException_SetTraceback(val, tb);
|
|
3573
|
+
}
|
|
3574
|
+
Py_XDECREF(t);
|
|
3575
|
+
Py_XDECREF(tb);
|
|
3576
|
+
c.exc = val;
|
|
3577
|
+
c.failed = true;
|
|
3578
|
+
break;
|
|
3579
|
+
}
|
|
3580
|
+
v = r;
|
|
3581
|
+
}
|
|
3582
|
+
int rc = PyList_Append(c.items, v);
|
|
3583
|
+
Py_DECREF(v);
|
|
3584
|
+
if (rc < 0) {
|
|
3585
|
+
PyErr_Clear();
|
|
3586
|
+
c.failed = true;
|
|
3587
|
+
break;
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
Py_XDECREF(reinterpret_cast<PyObject *>(st));
|
|
3592
|
+
if (c.failed) {
|
|
3593
|
+
note_failure(i);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
// Takes chunks until none is left, letting the garbage collector of
|
|
3598
|
+
// other threads run between chunks.
|
|
3599
|
+
void work() {
|
|
3600
|
+
for (;;) {
|
|
3601
|
+
size_t i = next.fetch_add(1);
|
|
3602
|
+
if (i >= chunks.size() || i > first_failure.load()) {
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
run_chunk(i);
|
|
3606
|
+
Py_BEGIN_ALLOW_THREADS
|
|
3607
|
+
Py_END_ALLOW_THREADS
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
};
|
|
3611
|
+
|
|
3612
|
+
// Chunk boundaries: about `n` chunks, each ending just after a newline (or
|
|
3613
|
+
// just before a record separator, for json_seq).
|
|
3614
|
+
std::vector<size_t> chunk_bounds(const StreamObject *w, size_t n) {
|
|
3615
|
+
std::vector<size_t> b{0};
|
|
3616
|
+
size_t step = std::max<size_t>(w->len / std::max<size_t>(n, 1), 1 << 16);
|
|
3617
|
+
bool seq = w->format == simdjson::stream_format::json_sequence;
|
|
3618
|
+
for (size_t at = step; at < w->len; at = b.back() + step) {
|
|
3619
|
+
const void *hit = memchr(w->buf + at, seq ? '\x1e' : '\n', w->len - at);
|
|
3620
|
+
if (hit == nullptr) {
|
|
3621
|
+
break;
|
|
3622
|
+
}
|
|
3623
|
+
size_t cut = size_t(static_cast<const char *>(hit) - w->buf) + (seq ? 0 : 1);
|
|
3624
|
+
if (cut >= w->len) {
|
|
3625
|
+
break;
|
|
3626
|
+
}
|
|
3627
|
+
b.push_back(cut);
|
|
3628
|
+
}
|
|
3629
|
+
b.push_back(w->len);
|
|
3630
|
+
return b;
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
// All the results (documents, or fn of each) of the stream `whole`, as a
|
|
3634
|
+
// list, using up to `threads` threads.
|
|
3635
|
+
PyObject *run_stream(StreamObject *whole, PyObject *fn, size_t threads) {
|
|
3636
|
+
PyObject *result = PyList_New(0);
|
|
3637
|
+
if (result == nullptr) {
|
|
3638
|
+
return nullptr;
|
|
3639
|
+
}
|
|
3640
|
+
size_t resume_at = 0;
|
|
3641
|
+
#ifdef Py_GIL_DISABLED
|
|
3642
|
+
if (threads > 1 && whole->len >= (size_t(1) << 17)) {
|
|
3643
|
+
ParallelRun run;
|
|
3644
|
+
run.whole = whole;
|
|
3645
|
+
run.fn = fn;
|
|
3646
|
+
run.bounds = chunk_bounds(whole, threads * 8);
|
|
3647
|
+
run.chunks.resize(run.bounds.size() - 1);
|
|
3648
|
+
// Starting a thread costs about as much as building 512 KB of documents.
|
|
3649
|
+
size_t nthreads = std::min({threads, run.chunks.size(), std::max<size_t>(whole->len >> 19, 1)});
|
|
3650
|
+
std::vector<std::thread> pool;
|
|
3651
|
+
PyInterpreterState *interp = PyInterpreterState_Get();
|
|
3652
|
+
for (size_t t = 1; t < nthreads; t++) {
|
|
3653
|
+
try {
|
|
3654
|
+
pool.emplace_back([&run, interp] {
|
|
3655
|
+
PyThreadState *ts = PyThreadState_New(interp);
|
|
3656
|
+
if (ts == nullptr) {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3659
|
+
PyEval_RestoreThread(ts);
|
|
3660
|
+
run.work();
|
|
3661
|
+
PyThreadState_Clear(ts);
|
|
3662
|
+
PyThreadState_DeleteCurrent();
|
|
3663
|
+
});
|
|
3664
|
+
} catch (...) {
|
|
3665
|
+
break; // fewer threads
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
run.work(); // the calling thread too
|
|
3669
|
+
Py_BEGIN_ALLOW_THREADS
|
|
3670
|
+
for (std::thread &t : pool) {
|
|
3671
|
+
t.join();
|
|
3672
|
+
}
|
|
3673
|
+
Py_END_ALLOW_THREADS
|
|
3674
|
+
size_t f = run.first_failure.load();
|
|
3675
|
+
size_t total = 0;
|
|
3676
|
+
for (size_t i = 0; i < run.chunks.size() && i <= f; i++) {
|
|
3677
|
+
total += run.chunks[i].items ? size_t(PyList_GET_SIZE(run.chunks[i].items)) : 0;
|
|
3678
|
+
}
|
|
3679
|
+
PyObject *exc = nullptr;
|
|
3680
|
+
int rc = 0;
|
|
3681
|
+
if (total > 0) {
|
|
3682
|
+
Py_DECREF(result);
|
|
3683
|
+
result = PyList_New(Py_ssize_t(total));
|
|
3684
|
+
rc = result == nullptr ? -1 : 0;
|
|
3685
|
+
}
|
|
3686
|
+
size_t k = 0;
|
|
3687
|
+
for (size_t i = 0; i < run.chunks.size(); i++) {
|
|
3688
|
+
ParallelRun::Chunk &c = run.chunks[i];
|
|
3689
|
+
if (c.items != nullptr && i <= f && rc == 0) {
|
|
3690
|
+
for (Py_ssize_t j = 0; j < PyList_GET_SIZE(c.items); j++) {
|
|
3691
|
+
PyList_SET_ITEM(result, Py_ssize_t(k++), Py_NewRef(PyList_GET_ITEM(c.items, j)));
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
Py_XDECREF(c.items);
|
|
3695
|
+
if (i == f) {
|
|
3696
|
+
exc = c.exc;
|
|
3697
|
+
resume_at = c.resume_at;
|
|
3698
|
+
} else {
|
|
3699
|
+
Py_XDECREF(c.exc);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
if (rc < 0) {
|
|
3703
|
+
Py_XDECREF(exc);
|
|
3704
|
+
return nullptr;
|
|
3705
|
+
}
|
|
3706
|
+
if (exc != nullptr) { // fn raised: as it would have without threads
|
|
3707
|
+
Py_DECREF(result);
|
|
3708
|
+
PyErr_SetObject(reinterpret_cast<PyObject *>(Py_TYPE(exc)), exc);
|
|
3709
|
+
Py_DECREF(exc);
|
|
3710
|
+
return nullptr;
|
|
3711
|
+
}
|
|
3712
|
+
if (f == SIZE_MAX) {
|
|
3713
|
+
return result;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
#else
|
|
3717
|
+
(void)threads;
|
|
3718
|
+
#endif
|
|
3719
|
+
// One thread, from resume_at to the end.
|
|
3720
|
+
StreamObject *st = resume_at == 0 ? reinterpret_cast<StreamObject *>(
|
|
3721
|
+
Py_NewRef(reinterpret_cast<PyObject *>(whole)))
|
|
3722
|
+
: stream_over(whole, resume_at, whole->len);
|
|
3723
|
+
if (st == nullptr) {
|
|
3724
|
+
Py_DECREF(result);
|
|
3725
|
+
return nullptr;
|
|
3726
|
+
}
|
|
3727
|
+
for (;;) {
|
|
3728
|
+
PyObject *v = stream_next_locked(st);
|
|
3729
|
+
if (v == nullptr) {
|
|
3730
|
+
break;
|
|
3731
|
+
}
|
|
3732
|
+
if (fn != nullptr) {
|
|
3733
|
+
PyObject *r = PyObject_CallOneArg(fn, v);
|
|
3734
|
+
Py_DECREF(v);
|
|
3735
|
+
if (r == nullptr) {
|
|
3736
|
+
break;
|
|
3737
|
+
}
|
|
3738
|
+
v = r;
|
|
3739
|
+
}
|
|
3740
|
+
int rc = PyList_Append(result, v);
|
|
3741
|
+
Py_DECREF(v);
|
|
3742
|
+
if (rc < 0) {
|
|
3743
|
+
break;
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
Py_DECREF(reinterpret_cast<PyObject *>(st));
|
|
3747
|
+
if (PyErr_Occurred()) {
|
|
3748
|
+
Py_DECREF(result);
|
|
3749
|
+
return nullptr;
|
|
3750
|
+
}
|
|
3751
|
+
return result;
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
// Removes the keyword `threads` from kwargs (into *threads: 0 when absent or
|
|
3755
|
+
// None, else at least 1); returns the remaining keywords (new reference).
|
|
3756
|
+
PyObject *take_threads(PyObject *kwargs, Py_ssize_t *threads, bool *given) {
|
|
3757
|
+
*threads = 0;
|
|
3758
|
+
*given = false;
|
|
3759
|
+
if (kwargs == nullptr) {
|
|
3760
|
+
return nullptr;
|
|
3761
|
+
}
|
|
3762
|
+
PyObject *rest = PyDict_Copy(kwargs);
|
|
3763
|
+
if (rest == nullptr) {
|
|
3764
|
+
return nullptr;
|
|
3765
|
+
}
|
|
3766
|
+
PyObject *t = PyDict_GetItemString(rest, "threads"); // borrowed
|
|
3767
|
+
if (t == nullptr) {
|
|
3768
|
+
return rest;
|
|
3769
|
+
}
|
|
3770
|
+
Py_INCREF(t);
|
|
3771
|
+
PyDict_DelItemString(rest, "threads");
|
|
3772
|
+
*given = true;
|
|
3773
|
+
if (t != Py_None) {
|
|
3774
|
+
Py_ssize_t n = PyNumber_AsSsize_t(t, PyExc_OverflowError);
|
|
3775
|
+
if (n == -1 && PyErr_Occurred()) {
|
|
3776
|
+
Py_DECREF(t);
|
|
3777
|
+
Py_DECREF(rest);
|
|
3778
|
+
return nullptr;
|
|
3779
|
+
}
|
|
3780
|
+
if (n < 0) {
|
|
3781
|
+
PyErr_SetString(PyExc_ValueError, "threads must be at least 0 (0: one per processor)");
|
|
3782
|
+
Py_DECREF(t);
|
|
3783
|
+
Py_DECREF(rest);
|
|
3784
|
+
return nullptr;
|
|
3785
|
+
}
|
|
3786
|
+
*threads = n == 0 ? Py_ssize_t(std::max(1u, std::thread::hardware_concurrency())) : n;
|
|
3787
|
+
} else {
|
|
3788
|
+
*threads = Py_ssize_t(std::max(1u, std::thread::hardware_concurrency()));
|
|
3789
|
+
}
|
|
3790
|
+
Py_DECREF(t);
|
|
3791
|
+
return rest;
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
// The stream for loads_many/parse_many/map_many; with threads, the list of
|
|
3795
|
+
// all results instead.
|
|
3796
|
+
PyObject *stream_or_list(PyObject *args, PyObject *kwargs, bool lazy, PyObject *fn,
|
|
3797
|
+
bool force_list) {
|
|
3798
|
+
Py_ssize_t threads;
|
|
3799
|
+
bool given;
|
|
3800
|
+
PyObject *rest = take_threads(kwargs, &threads, &given);
|
|
3801
|
+
if (rest == nullptr && PyErr_Occurred()) {
|
|
3802
|
+
return nullptr;
|
|
3803
|
+
}
|
|
3804
|
+
PyObject *self = make_stream(args, rest, lazy);
|
|
3805
|
+
Py_XDECREF(rest);
|
|
3806
|
+
if (self == nullptr) {
|
|
3807
|
+
return nullptr;
|
|
3808
|
+
}
|
|
3809
|
+
if (!given && !force_list) {
|
|
3810
|
+
return self;
|
|
3811
|
+
}
|
|
3812
|
+
StreamObject *whole = reinterpret_cast<StreamObject *>(self);
|
|
3813
|
+
using simdjson::stream_format;
|
|
3814
|
+
if (given && whole->format != stream_format::whitespace_delimited &&
|
|
3815
|
+
whole->format != stream_format::newline_delimited &&
|
|
3816
|
+
whole->format != stream_format::json_sequence) {
|
|
3817
|
+
Py_DECREF(self);
|
|
3818
|
+
PyErr_SetString(PyExc_ValueError,
|
|
3819
|
+
"threads requires the format 'whitespace', 'lines' or 'json_seq'");
|
|
3820
|
+
return nullptr;
|
|
3821
|
+
}
|
|
3822
|
+
PyObject *r = run_stream(whole, fn, given ? size_t(threads) : 1);
|
|
3823
|
+
Py_DECREF(self);
|
|
3824
|
+
return r;
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3406
3827
|
PyObject *loads_many(PyObject *, PyObject *args, PyObject *kwargs) {
|
|
3407
|
-
return
|
|
3828
|
+
return stream_or_list(args, kwargs, false, nullptr, false);
|
|
3408
3829
|
}
|
|
3409
3830
|
|
|
3410
3831
|
PyObject *parse_many(PyObject *, PyObject *args, PyObject *kwargs) {
|
|
3411
|
-
return
|
|
3832
|
+
return stream_or_list(args, kwargs, true, nullptr, false);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
// map_many(fn, data, *, format, batch_size, threads, lazy=False): [fn(doc) for
|
|
3836
|
+
// doc in loads_many(data)] (parse_many with lazy=True), with fn called in the
|
|
3837
|
+
// threads.
|
|
3838
|
+
PyObject *map_many(PyObject *, PyObject *args, PyObject *kwargs) {
|
|
3839
|
+
if (PyTuple_GET_SIZE(args) < 1) {
|
|
3840
|
+
PyErr_SetString(PyExc_TypeError, "map_many() missing required argument 'fn'");
|
|
3841
|
+
return nullptr;
|
|
3842
|
+
}
|
|
3843
|
+
PyObject *fn = PyTuple_GET_ITEM(args, 0);
|
|
3844
|
+
if (!PyCallable_Check(fn)) {
|
|
3845
|
+
PyErr_SetString(PyExc_TypeError, "map_many() fn must be callable");
|
|
3846
|
+
return nullptr;
|
|
3847
|
+
}
|
|
3848
|
+
bool lazy = false;
|
|
3849
|
+
PyObject *rest = kwargs ? PyDict_Copy(kwargs) : nullptr;
|
|
3850
|
+
if (kwargs != nullptr && rest == nullptr) {
|
|
3851
|
+
return nullptr;
|
|
3852
|
+
}
|
|
3853
|
+
if (rest != nullptr) {
|
|
3854
|
+
PyObject *l = PyDict_GetItemString(rest, "lazy"); // borrowed
|
|
3855
|
+
if (l != nullptr) {
|
|
3856
|
+
int t = PyObject_IsTrue(l);
|
|
3857
|
+
if (t < 0 || PyDict_DelItemString(rest, "lazy") < 0) {
|
|
3858
|
+
Py_DECREF(rest);
|
|
3859
|
+
return nullptr;
|
|
3860
|
+
}
|
|
3861
|
+
lazy = t != 0;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
PyObject *tail = PyTuple_GetSlice(args, 1, PyTuple_GET_SIZE(args));
|
|
3865
|
+
if (tail == nullptr) {
|
|
3866
|
+
Py_XDECREF(rest);
|
|
3867
|
+
return nullptr;
|
|
3868
|
+
}
|
|
3869
|
+
PyObject *r = stream_or_list(tail, rest, lazy, fn, true);
|
|
3870
|
+
Py_DECREF(tail);
|
|
3871
|
+
Py_XDECREF(rest);
|
|
3872
|
+
return r;
|
|
3412
3873
|
}
|
|
3413
3874
|
|
|
3414
3875
|
// Benchmarking helper: run simdjson without building Python objects.
|
|
@@ -3428,12 +3889,7 @@ PyObject *parse_only(PyObject *, PyObject *arg) {
|
|
|
3428
3889
|
}
|
|
3429
3890
|
|
|
3430
3891
|
PyObject *release(PyObject *, PyObject *) {
|
|
3431
|
-
|
|
3432
|
-
delete g_thread_parser.spare;
|
|
3433
|
-
g_thread_parser.spare = nullptr;
|
|
3434
|
-
clear_cache(g_thread_parser.key_cache);
|
|
3435
|
-
clear_cache(g_thread_parser.value_cache);
|
|
3436
|
-
g_thread_parser.clear_encoded_keys();
|
|
3892
|
+
release_thread_state();
|
|
3437
3893
|
Py_RETURN_NONE;
|
|
3438
3894
|
}
|
|
3439
3895
|
|
|
@@ -3454,10 +3910,15 @@ PyMethodDef methods[] = {
|
|
|
3454
3910
|
{"parse_file", parse_file, METH_O, "Parse the JSON file at path lazily, like parse."},
|
|
3455
3911
|
{"loads_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(loads_many)),
|
|
3456
3912
|
METH_VARARGS | METH_KEYWORDS,
|
|
3457
|
-
"Iterate over the JSON documents of data (NDJSON, JSON Lines, ...), like loads
|
|
3913
|
+
"Iterate over the JSON documents of data (NDJSON, JSON Lines, ...), like loads; "
|
|
3914
|
+
"with threads=N, the list of the documents, built by N threads."},
|
|
3458
3915
|
{"parse_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(parse_many)),
|
|
3459
3916
|
METH_VARARGS | METH_KEYWORDS,
|
|
3460
3917
|
"Iterate over the JSON documents of data lazily, like parse."},
|
|
3918
|
+
{"map_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(map_many)),
|
|
3919
|
+
METH_VARARGS | METH_KEYWORDS,
|
|
3920
|
+
"map_many(fn, data, *, format, batch_size, threads, lazy): the list of fn(doc) for the "
|
|
3921
|
+
"documents of data, with fn called in the threads."},
|
|
3461
3922
|
{"parse", parse, METH_O,
|
|
3462
3923
|
"Parse JSON lazily: objects and arrays are returned as read-only views "
|
|
3463
3924
|
"(Object, Array) whose values are converted on access."},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastsimdjson
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Fast JSON parsing for Python, built on simdjson
|
|
5
5
|
Author-email: Daniel Lemire <daniel@lemire.me>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -197,6 +197,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
|
|
|
197
197
|
position in the whole input. A truncated last document is an error. The
|
|
198
198
|
views returned by `parse_many` remain valid after the iterator moves on.
|
|
199
199
|
|
|
200
|
+
### Streams on several threads: `threads=N` and `map_many`
|
|
201
|
+
|
|
202
|
+
On free-threaded Python (3.14t and newer), a stream can be parsed by several
|
|
203
|
+
threads at once:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
|
|
207
|
+
views = fastsimdjson.parse_many(data, threads=16) # a list of views
|
|
208
|
+
# fn runs in the threads too, on each document; its results, in order:
|
|
209
|
+
scores = fastsimdjson.map_many(score, data, threads=16)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With `threads`, `loads_many` and `parse_many` return a list of all the
|
|
213
|
+
documents instead of an iterator. `map_many(fn, data, **kw)` returns
|
|
214
|
+
`[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
|
|
215
|
+
`lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
|
|
216
|
+
one thread per processor; small inputs use fewer threads (about one per
|
|
217
|
+
512 KB). The input is cut into chunks at newlines (before each `\x1e` for
|
|
218
|
+
`json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
|
|
219
|
+
and `"json_seq"`.
|
|
220
|
+
|
|
221
|
+
The results, and the exceptions, are those of one thread: when a chunk
|
|
222
|
+
contains an error or a document that only `json` accepts, the documents
|
|
223
|
+
before it are kept and the rest is parsed in the calling thread, as without
|
|
224
|
+
threads. An exception raised by `fn` is raised again, for the first document
|
|
225
|
+
where it happened; `fn` may then have been called on later documents, in
|
|
226
|
+
other threads, and it must be safe to call from several threads at once.
|
|
227
|
+
|
|
228
|
+
`map_many` scales best: each document is built, used and freed in the same
|
|
229
|
+
thread, and only `fn`'s results come back. `loads_many` must return every
|
|
230
|
+
document, and the Python objects of all of them, to the calling thread.
|
|
231
|
+
With the GIL (the default build of Python), the calling thread does all the
|
|
232
|
+
work: `threads` has no effect on speed.
|
|
233
|
+
|
|
200
234
|
### `release`
|
|
201
235
|
|
|
202
236
|
`release()` frees the simdjson parser and the string caches kept by the
|
|
@@ -433,14 +467,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
|
|
|
433
467
|
faster). `parse_many` only creates the views; reading values from them adds to
|
|
434
468
|
its time.
|
|
435
469
|
|
|
470
|
+
### Streams on several threads
|
|
471
|
+
|
|
472
|
+
300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
|
|
473
|
+
gsoc and update-center entries), free-threaded Python 3.14.6, collector
|
|
474
|
+
disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
|
|
475
|
+
|
|
476
|
+
| threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|
|
477
|
+
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
478
|
+
| `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
|
|
479
|
+
| `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
|
|
480
|
+
| `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
|
|
481
|
+
|
|
482
|
+
`map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
|
|
483
|
+
every document for the calling thread and is 11 times faster. With the
|
|
484
|
+
collector enabled, Python collects after the call returns: it examines the
|
|
485
|
+
millions of objects just created, which takes about 0.1 s here, with or
|
|
486
|
+
without threads.
|
|
487
|
+
|
|
436
488
|
## Limitations
|
|
437
489
|
|
|
438
490
|
* The simdjson parser and the key and string caches are thread-local.
|
|
439
491
|
`release()` frees the parser and the cached strings retained by the calling
|
|
440
|
-
thread
|
|
441
|
-
|
|
442
|
-
its cached strings behind.
|
|
492
|
+
thread, and they are freed when a Python thread exits. A parser that grows
|
|
493
|
+
past 64 MB is freed on its own at the end of that call; its caches stay.
|
|
443
494
|
* The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
|
|
444
495
|
Python 3.13 and newer), so importing it on a free-threaded build does not
|
|
445
|
-
re-enable the GIL.
|
|
446
|
-
|
|
496
|
+
re-enable the GIL. It is tested with many threads parsing, serializing and
|
|
497
|
+
sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
|
|
498
|
+
On a free-threaded build, `bytearray` and `memoryview` inputs are copied
|
|
499
|
+
before parsing, and `dumps` and `dumpb` hold references to the items of the
|
|
500
|
+
containers they serialize, since other threads may change them.
|
|
501
|
+
Subinterpreters are not supported.
|
|
@@ -415,3 +415,23 @@ def test_dumpb_matches_orjson_cases():
|
|
|
415
415
|
for obj, option, _ in ORJSON_CASES:
|
|
416
416
|
assert orjson_outcome(fastsimdjson.dumpb, obj, option=option) == \
|
|
417
417
|
orjson_outcome(orjson.dumps, obj, option=option), (obj, option)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_thread_exit_releases_caches():
|
|
421
|
+
# A key that enters dumpb's key cache (seen twice) is referenced by the
|
|
422
|
+
# cache; when the thread exits, its caches are released.
|
|
423
|
+
import sys
|
|
424
|
+
import threading
|
|
425
|
+
|
|
426
|
+
key = "".join(["thread", "-exit-", "key"]) # not interned
|
|
427
|
+
before = sys.getrefcount(key)
|
|
428
|
+
|
|
429
|
+
def work():
|
|
430
|
+
for _ in range(3):
|
|
431
|
+
assert fastsimdjson.dumpb({key: 1}) == b'{"thread-exit-key":1}'
|
|
432
|
+
fastsimdjson.loads(b'{"a": "b"}')
|
|
433
|
+
|
|
434
|
+
t = threading.Thread(target=work)
|
|
435
|
+
t.start()
|
|
436
|
+
t.join()
|
|
437
|
+
assert sys.getrefcount(key) == before
|
|
@@ -222,3 +222,69 @@ def test_fallback_needs_the_separator():
|
|
|
222
222
|
with pytest.raises(fastsimdjson.JSONDecodeError) as info:
|
|
223
223
|
list(fastsimdjson.loads_many("[1e400 2]", format="array"))
|
|
224
224
|
assert info.value.msg == "Expecting ',' delimiter"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# threads=N: the list of all documents, as without threads. Inputs over
|
|
228
|
+
# 128 KB, so that free-threaded builds use several threads.
|
|
229
|
+
|
|
230
|
+
def _outcome(fn):
|
|
231
|
+
try:
|
|
232
|
+
return ("ok", fn())
|
|
233
|
+
except Exception as e: # noqa: BLE001
|
|
234
|
+
return (type(e).__name__, str(e), getattr(e, "pos", None))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _threaded_input(seed, fmt):
|
|
238
|
+
r = random.Random(seed)
|
|
239
|
+
docs = []
|
|
240
|
+
for i in range(6000):
|
|
241
|
+
c = r.randrange(100)
|
|
242
|
+
if c == 0:
|
|
243
|
+
docs.append(b"[1e400]") # json only: inf
|
|
244
|
+
elif c == 1:
|
|
245
|
+
docs.append(b'{"big": %d}' % (10**30 + i))
|
|
246
|
+
elif c == 2 and fmt == "whitespace":
|
|
247
|
+
docs.append(b'{\n "pretty": [1,\n 2]\n}') # spans lines
|
|
248
|
+
else:
|
|
249
|
+
docs.append(json.dumps({"i": i, "s": "x" * r.randrange(60), "f": i / 7}).encode())
|
|
250
|
+
if seed % 3 == 1:
|
|
251
|
+
docs[r.randrange(len(docs))] = b'{"broken": [1, 2' # an error somewhere
|
|
252
|
+
if seed % 3 == 2:
|
|
253
|
+
docs[r.randrange(len(docs))] = b'"\\ud800"' # json only: lone surrogate
|
|
254
|
+
sep = b"\n\x1e" if fmt == "json_seq" else b"\n"
|
|
255
|
+
data = sep.join(docs)
|
|
256
|
+
return b"\x1e" + data if fmt == "json_seq" else data
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.parametrize("fmt", ["whitespace", "lines", "json_seq"])
|
|
260
|
+
@pytest.mark.parametrize("seed", range(9))
|
|
261
|
+
def test_threads_match_one_thread(fmt, seed):
|
|
262
|
+
data = _threaded_input(seed, fmt)
|
|
263
|
+
expected = _outcome(lambda: list(fastsimdjson.loads_many(data, format=fmt)))
|
|
264
|
+
for threads in (None, 0, 1, 2, 7):
|
|
265
|
+
assert _outcome(lambda: fastsimdjson.loads_many(data, format=fmt, threads=threads)) == expected
|
|
266
|
+
lazy = _outcome(lambda: [to_py(v) for v in fastsimdjson.parse_many(data, format=fmt, threads=4)])
|
|
267
|
+
assert lazy == expected
|
|
268
|
+
mapped = _outcome(lambda: fastsimdjson.map_many(json.dumps, data, format=fmt, threads=4))
|
|
269
|
+
assert mapped == (_outcome(lambda: [json.dumps(d) for d in expected[1]]) if expected[0] == "ok" else expected)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_threads_api():
|
|
273
|
+
data = b"\n".join(b'{"n": %d}' % i for i in range(30000))
|
|
274
|
+
docs = fastsimdjson.loads_many(data, threads=4)
|
|
275
|
+
assert isinstance(docs, list) and docs == [{"n": i} for i in range(30000)]
|
|
276
|
+
assert fastsimdjson.map_many(lambda d: d["n"], data, threads=3) == list(range(30000))
|
|
277
|
+
assert fastsimdjson.map_many(lambda v: v["n"], data, threads=3, lazy=True) == list(range(30000))
|
|
278
|
+
assert fastsimdjson.map_many(len, b"") == []
|
|
279
|
+
with pytest.raises(ValueError):
|
|
280
|
+
fastsimdjson.loads_many(b"[1],[2]", format="comma", threads=2)
|
|
281
|
+
with pytest.raises(ValueError):
|
|
282
|
+
fastsimdjson.loads_many(data, threads=-1)
|
|
283
|
+
|
|
284
|
+
def boom(d):
|
|
285
|
+
if d["n"] == 20000:
|
|
286
|
+
raise KeyError("boom")
|
|
287
|
+
return d
|
|
288
|
+
|
|
289
|
+
with pytest.raises(KeyError, match="boom"):
|
|
290
|
+
fastsimdjson.map_many(boom, data, threads=4)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|