fastsimdjson 0.7.0__tar.gz → 0.8.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.
Files changed (25) hide show
  1. {fastsimdjson-0.7.0/src/fastsimdjson.egg-info → fastsimdjson-0.8.0}/PKG-INFO +49 -7
  2. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/README.md +48 -6
  3. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/pyproject.toml +1 -1
  4. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/src/fastsimdjson.cpp +379 -38
  5. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0/src/fastsimdjson.egg-info}/PKG-INFO +49 -7
  6. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/tests/test_dumps.py +112 -0
  7. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/MANIFEST.in +0 -0
  8. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/setup.cfg +0 -0
  9. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/setup.py +0 -0
  10. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/src/fastsimdjson.egg-info/SOURCES.txt +0 -0
  11. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/src/fastsimdjson.egg-info/dependency_links.txt +0 -0
  12. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/src/fastsimdjson.egg-info/requires.txt +0 -0
  13. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/src/fastsimdjson.egg-info/top_level.txt +0 -0
  14. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/tests/test_files.py +0 -0
  15. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/tests/test_lazy.py +0 -0
  16. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/tests/test_loads.py +0 -0
  17. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/tests/test_stream.py +0 -0
  18. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/simdjson.cpp +0 -0
  19. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/simdjson.h +0 -0
  20. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/simdutf.cpp +0 -0
  21. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/simdutf.h +0 -0
  22. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/simdutf_c.h +0 -0
  23. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/zmij-LICENSE +0 -0
  24. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/zmij.cc +0 -0
  25. {fastsimdjson-0.7.0 → fastsimdjson-0.8.0}/vendor/zmij.h +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -42,7 +42,8 @@ function is faster still. It also reads streams of documents (NDJSON, JSON
42
42
  Lines), on many threads with free-threaded Python, and can extract only the
43
43
  fields you need from each document. It writes JSON in two ways: `dumps` returns the same `str` as
44
44
  `json.dumps`, and `dumpb` returns the same `bytes` as `orjson.dumps`, as fast
45
- as orjson.
45
+ as orjson; `dumpb_many` writes NDJSON, on many threads with free-threaded
46
+ Python.
46
47
 
47
48
  With pip:
48
49
 
@@ -158,6 +159,30 @@ serialize dataclasses, `datetime`, `date`, `time`, `UUID`, numpy arrays or
158
159
  `OPT_PASSTHROUGH_DATACLASS` and `OPT_PASSTHROUGH_DATETIME` were set, and the
159
160
  options that concern only these types are accepted and have no effect.
160
161
 
162
+ ### `dumpb_many`: NDJSON, on several threads
163
+
164
+ ```python
165
+ data = fastsimdjson.dumpb_many(records) # b'{...}\n{...}\n'
166
+ data = fastsimdjson.dumpb_many(records, threads=16) # free-threaded Python
167
+ ```
168
+
169
+ `dumpb_many(records, default=None, option=None, *, threads=None)` returns
170
+ the records (a list, a tuple or any iterable) as NDJSON `bytes`: each record
171
+ as `dumpb` writes it, followed by a newline, so that the output reads back
172
+ with `loads_many(data, format="lines")`. `default` and `option` are those of
173
+ `dumpb`, except `OPT_INDENT_2`, which would spread a record over several
174
+ lines (`ValueError`); `OPT_APPEND_NEWLINE` changes nothing.
175
+
176
+ On free-threaded Python, `threads=N` writes chunks of records on N threads at
177
+ once (`threads=0` or `None`: one per processor), with the worker threads of
178
+ `map_many`. The output and the errors are those of one thread: when records
179
+ cannot be serialized, the error of the first of them is raised (`default` may
180
+ then have been called on later records, in other threads, and it must be
181
+ safe to call from several threads at once). With the GIL, the calling thread
182
+ does all the work. Like `dumpb`, `dumpb_many` does not crash when other
183
+ threads change the records meanwhile, but the output then reflects some of
184
+ the changes and not others.
185
+
161
186
  ### Files
162
187
 
163
188
  ```python
@@ -380,7 +405,7 @@ geometric mean over the 22 files (higher is better).
380
405
 
381
406
  <picture>
382
407
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads_dark.png">
383
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
408
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, jiter without its string cache 0.44, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
384
409
  </picture>
385
410
 
386
411
  | parser | GB/s | vs `json.loads` |
@@ -391,7 +416,8 @@ geometric mean over the 22 files (higher is better).
391
416
  | ujson 6.0.0 | 0.37 | 1.66× |
392
417
  | cysimdjson 26.27 | 0.43 | 1.96× |
393
418
  | pysimdjson 7.0.2 | 0.44 | 1.98× |
394
- | jiter 0.17.0 | 0.48 | 2.21× |
419
+ | jiter 0.17.0 (no string cache) | 0.44 | 2.00× |
420
+ | jiter 0.17.0 (string cache, the default) | 0.48 | 2.21× |
395
421
  | msgspec 0.22.0 | 0.53 | 2.42× |
396
422
  | orjson 3.12.0 | 0.60 | 2.74× |
397
423
  | **fastsimdjson `loads`** | **0.78** | **3.53×** |
@@ -407,7 +433,7 @@ the collector enabled and 1.22× with it disabled (geometric means); without
407
433
  the collector, `canada.json`, `mesh.json` and `numbers.json` are ties. yyjson 4.0.6 is left out: it
408
434
  returns wrong strings for non-ASCII text. jiter (the parser of pydantic)
409
435
  caches strings by default; turning the cache off (`cache_mode=False`) makes it
410
- slower here (0.44 GB/s).
436
+ slower here.
411
437
 
412
438
  Parsing is no longer the bottleneck. simdjson alone parses these files at
413
439
  3.1 GB/s. It accounts for about a third of the time of `loads`; the rest goes
@@ -422,12 +448,14 @@ and the screen name of the 100 statuses of `twitter.json`:
422
448
 
423
449
  <picture>
424
450
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse_dark.png">
425
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, json 3922">
451
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, jiter 1281, jiter without its string cache 1592, json 3922">
426
452
  </picture>
427
453
 
428
454
  | method | µs |
429
455
  |---|---:|
430
456
  | `json.loads` | 3922 |
457
+ | jiter (no string cache) | 1592 |
458
+ | jiter (string cache) | 1281 |
431
459
  | orjson | 1009 |
432
460
  | fastsimdjson `loads` | 861 |
433
461
  | msgspec (typed `Struct`) | 336 |
@@ -481,6 +509,18 @@ times faster than msgspec and 10 times faster than `json`. The comparison was
481
509
  run on a processor with AVX-512, which `dumpb` and orjson both use to escape
482
510
  strings; other x64 processors use SSE2 and ARM processors NEON.
483
511
 
512
+ ### Writing NDJSON with `dumpb_many`
513
+
514
+ The 593,945 events of two hours of [GH Archive](https://www.gharchive.org/)
515
+ (1.87 GB of NDJSON), written back as NDJSON from Python objects. With the
516
+ GIL (Python 3.14.6), one thread: `orjson.dumps` on each record, joined, runs
517
+ at 0.94 GB/s, `dumpb` on each record at 1.07 GB/s, and `dumpb_many` at
518
+ 1.41 GB/s. On free-threaded Python 3.14.6t, GB/s:
519
+
520
+ | threads | 1 | 8 | 32 | 64 |
521
+ |---|---:|---:|---:|---:|
522
+ | `dumpb_many(records, threads=N)` | 1.3 | 6.0 | 11 | 12 |
523
+
484
524
  ### Streams with `loads_many`
485
525
 
486
526
  20 MB of NDJSON (5268 objects and arrays, one per line), made from the same
@@ -488,12 +528,14 @@ files; best of three runs:
488
528
 
489
529
  <picture>
490
530
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson_dark.png">
491
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, json per line 0.12">
531
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, jiter per line 0.22, jiter without its string cache per line 0.20, json per line 0.12">
492
532
  </picture>
493
533
 
494
534
  | method | ms | GB/s |
495
535
  |---|---:|---:|
496
536
  | `json.loads` on each line | 169 | 0.12 |
537
+ | jiter on each line (no string cache) | 98 | 0.20 |
538
+ | jiter on each line (string cache) | 90 | 0.22 |
497
539
  | orjson on each line | 77 | 0.26 |
498
540
  | msgspec `decode` on each line | 74 | 0.27 |
499
541
  | fastsimdjson `loads` on each line | 60 | 0.33 |
@@ -10,7 +10,8 @@ function is faster still. It also reads streams of documents (NDJSON, JSON
10
10
  Lines), on many threads with free-threaded Python, and can extract only the
11
11
  fields you need from each document. It writes JSON in two ways: `dumps` returns the same `str` as
12
12
  `json.dumps`, and `dumpb` returns the same `bytes` as `orjson.dumps`, as fast
13
- as orjson.
13
+ as orjson; `dumpb_many` writes NDJSON, on many threads with free-threaded
14
+ Python.
14
15
 
15
16
  With pip:
16
17
 
@@ -126,6 +127,30 @@ serialize dataclasses, `datetime`, `date`, `time`, `UUID`, numpy arrays or
126
127
  `OPT_PASSTHROUGH_DATACLASS` and `OPT_PASSTHROUGH_DATETIME` were set, and the
127
128
  options that concern only these types are accepted and have no effect.
128
129
 
130
+ ### `dumpb_many`: NDJSON, on several threads
131
+
132
+ ```python
133
+ data = fastsimdjson.dumpb_many(records) # b'{...}\n{...}\n'
134
+ data = fastsimdjson.dumpb_many(records, threads=16) # free-threaded Python
135
+ ```
136
+
137
+ `dumpb_many(records, default=None, option=None, *, threads=None)` returns
138
+ the records (a list, a tuple or any iterable) as NDJSON `bytes`: each record
139
+ as `dumpb` writes it, followed by a newline, so that the output reads back
140
+ with `loads_many(data, format="lines")`. `default` and `option` are those of
141
+ `dumpb`, except `OPT_INDENT_2`, which would spread a record over several
142
+ lines (`ValueError`); `OPT_APPEND_NEWLINE` changes nothing.
143
+
144
+ On free-threaded Python, `threads=N` writes chunks of records on N threads at
145
+ once (`threads=0` or `None`: one per processor), with the worker threads of
146
+ `map_many`. The output and the errors are those of one thread: when records
147
+ cannot be serialized, the error of the first of them is raised (`default` may
148
+ then have been called on later records, in other threads, and it must be
149
+ safe to call from several threads at once). With the GIL, the calling thread
150
+ does all the work. Like `dumpb`, `dumpb_many` does not crash when other
151
+ threads change the records meanwhile, but the output then reflects some of
152
+ the changes and not others.
153
+
129
154
  ### Files
130
155
 
131
156
  ```python
@@ -348,7 +373,7 @@ geometric mean over the 22 files (higher is better).
348
373
 
349
374
  <picture>
350
375
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads_dark.png">
351
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
376
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, jiter without its string cache 0.44, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
352
377
  </picture>
353
378
 
354
379
  | parser | GB/s | vs `json.loads` |
@@ -359,7 +384,8 @@ geometric mean over the 22 files (higher is better).
359
384
  | ujson 6.0.0 | 0.37 | 1.66× |
360
385
  | cysimdjson 26.27 | 0.43 | 1.96× |
361
386
  | pysimdjson 7.0.2 | 0.44 | 1.98× |
362
- | jiter 0.17.0 | 0.48 | 2.21× |
387
+ | jiter 0.17.0 (no string cache) | 0.44 | 2.00× |
388
+ | jiter 0.17.0 (string cache, the default) | 0.48 | 2.21× |
363
389
  | msgspec 0.22.0 | 0.53 | 2.42× |
364
390
  | orjson 3.12.0 | 0.60 | 2.74× |
365
391
  | **fastsimdjson `loads`** | **0.78** | **3.53×** |
@@ -375,7 +401,7 @@ the collector enabled and 1.22× with it disabled (geometric means); without
375
401
  the collector, `canada.json`, `mesh.json` and `numbers.json` are ties. yyjson 4.0.6 is left out: it
376
402
  returns wrong strings for non-ASCII text. jiter (the parser of pydantic)
377
403
  caches strings by default; turning the cache off (`cache_mode=False`) makes it
378
- slower here (0.44 GB/s).
404
+ slower here.
379
405
 
380
406
  Parsing is no longer the bottleneck. simdjson alone parses these files at
381
407
  3.1 GB/s. It accounts for about a third of the time of `loads`; the rest goes
@@ -390,12 +416,14 @@ and the screen name of the 100 statuses of `twitter.json`:
390
416
 
391
417
  <picture>
392
418
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse_dark.png">
393
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, json 3922">
419
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, jiter 1281, jiter without its string cache 1592, json 3922">
394
420
  </picture>
395
421
 
396
422
  | method | µs |
397
423
  |---|---:|
398
424
  | `json.loads` | 3922 |
425
+ | jiter (no string cache) | 1592 |
426
+ | jiter (string cache) | 1281 |
399
427
  | orjson | 1009 |
400
428
  | fastsimdjson `loads` | 861 |
401
429
  | msgspec (typed `Struct`) | 336 |
@@ -449,6 +477,18 @@ times faster than msgspec and 10 times faster than `json`. The comparison was
449
477
  run on a processor with AVX-512, which `dumpb` and orjson both use to escape
450
478
  strings; other x64 processors use SSE2 and ARM processors NEON.
451
479
 
480
+ ### Writing NDJSON with `dumpb_many`
481
+
482
+ The 593,945 events of two hours of [GH Archive](https://www.gharchive.org/)
483
+ (1.87 GB of NDJSON), written back as NDJSON from Python objects. With the
484
+ GIL (Python 3.14.6), one thread: `orjson.dumps` on each record, joined, runs
485
+ at 0.94 GB/s, `dumpb` on each record at 1.07 GB/s, and `dumpb_many` at
486
+ 1.41 GB/s. On free-threaded Python 3.14.6t, GB/s:
487
+
488
+ | threads | 1 | 8 | 32 | 64 |
489
+ |---|---:|---:|---:|---:|
490
+ | `dumpb_many(records, threads=N)` | 1.3 | 6.0 | 11 | 12 |
491
+
452
492
  ### Streams with `loads_many`
453
493
 
454
494
  20 MB of NDJSON (5268 objects and arrays, one per line), made from the same
@@ -456,12 +496,14 @@ files; best of three runs:
456
496
 
457
497
  <picture>
458
498
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson_dark.png">
459
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, json per line 0.12">
499
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, jiter per line 0.22, jiter without its string cache per line 0.20, json per line 0.12">
460
500
  </picture>
461
501
 
462
502
  | method | ms | GB/s |
463
503
  |---|---:|---:|
464
504
  | `json.loads` on each line | 169 | 0.12 |
505
+ | jiter on each line (no string cache) | 98 | 0.20 |
506
+ | jiter on each line (string cache) | 90 | 0.22 |
465
507
  | orjson on each line | 77 | 0.26 |
466
508
  | msgspec `decode` on each line | 74 | 0.27 |
467
509
  | fastsimdjson `loads` on each line | 60 | 0.33 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastsimdjson"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Fast JSON parsing for Python, built on simdjson"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -12,6 +12,7 @@
12
12
  #include <new>
13
13
  #include <condition_variable>
14
14
  #include <deque>
15
+ #include <memory>
15
16
  #include <mutex>
16
17
  #include <string>
17
18
  #include <thread>
@@ -2940,6 +2941,11 @@ struct OrjsonEncoder {
2940
2941
  }
2941
2942
  }
2942
2943
  } else {
2944
+ #ifdef Py_GIL_DISABLED
2945
+ if (encode_items_locked(o, is_list) < 0) {
2946
+ return -1;
2947
+ }
2948
+ #else
2943
2949
  for (Py_ssize_t i = 0;; i++) {
2944
2950
  PyObject *item = is_list ? list_item(o, i)
2945
2951
  : i < n ? Py_NewRef(PyTuple_GET_ITEM(o, i))
@@ -2954,6 +2960,7 @@ struct OrjsonEncoder {
2954
2960
  return -1;
2955
2961
  }
2956
2962
  }
2963
+ #endif
2957
2964
  }
2958
2965
  close(']');
2959
2966
  return 0;
@@ -3010,6 +3017,93 @@ struct OrjsonEncoder {
3010
3017
  return 0;
3011
3018
  }
3012
3019
 
3020
+ #ifdef Py_GIL_DISABLED
3021
+ // Free-threaded Python: a dict or list is locked while its items are
3022
+ // encoded, and the items are borrowed when that is safe. Taking references
3023
+ // instead (a snapshot of the items) is slow on many threads: the records
3024
+ // of a list often share their keys and short strings, and the threads then
3025
+ // all update the same reference counts. Encoding an item that may lock
3026
+ // another object or run Python code can suspend the lock (another thread
3027
+ // may then change the container), so such an item is referenced first:
3028
+ // anything but an exact int, float, ASCII str, None, True or False.
3029
+ static bool borrowable(PyObject *v) {
3030
+ PyTypeObject *t = Py_TYPE(v);
3031
+ return (t == &PyUnicode_Type && PyUnicode_IS_COMPACT_ASCII(v)) || t == &PyLong_Type ||
3032
+ t == &PyFloat_Type || v == Py_None || v == Py_True || v == Py_False;
3033
+ }
3034
+
3035
+ // References released when they go out of scope, also when the output
3036
+ // buffer throws std::bad_alloc.
3037
+ struct Refs {
3038
+ PyObject *a, *b;
3039
+ ~Refs() {
3040
+ Py_XDECREF(a);
3041
+ Py_XDECREF(b);
3042
+ }
3043
+ };
3044
+
3045
+ // The lock is released before std::bad_alloc goes further.
3046
+ FSJ_NOINLINE int encode_entries_locked(PyObject *o) {
3047
+ int rc = 0;
3048
+ bool oom = false;
3049
+ Py_BEGIN_CRITICAL_SECTION(o);
3050
+ try {
3051
+ Py_ssize_t pos = 0;
3052
+ PyObject *key, *value;
3053
+ bool first = true;
3054
+ while (rc == 0 && PyDict_Next(o, &pos, &key, &value)) {
3055
+ if (borrowable(key) && borrowable(value)) {
3056
+ rc = encode_entry(key, value, first);
3057
+ } else {
3058
+ Refs r{Py_NewRef(key), Py_NewRef(value)};
3059
+ rc = encode_entry(key, value, first);
3060
+ }
3061
+ first = false;
3062
+ }
3063
+ } catch (const std::bad_alloc &) {
3064
+ oom = true;
3065
+ }
3066
+ Py_END_CRITICAL_SECTION();
3067
+ if (oom) {
3068
+ throw std::bad_alloc();
3069
+ }
3070
+ return rc;
3071
+ }
3072
+
3073
+ // A tuple cannot change: its items live as long as it does.
3074
+ FSJ_NOINLINE int encode_items_locked(PyObject *o, bool is_list) {
3075
+ int rc = 0;
3076
+ if (!is_list) {
3077
+ for (Py_ssize_t i = 0; rc == 0 && i < PyTuple_GET_SIZE(o); i++) {
3078
+ separate(i == 0);
3079
+ rc = encode(PyTuple_GET_ITEM(o, i));
3080
+ }
3081
+ return rc;
3082
+ }
3083
+ bool oom = false;
3084
+ Py_BEGIN_CRITICAL_SECTION(o);
3085
+ try {
3086
+ for (Py_ssize_t i = 0; rc == 0 && i < PyList_GET_SIZE(o); i++) {
3087
+ PyObject *item = PyList_GET_ITEM(o, i);
3088
+ separate(i == 0);
3089
+ if (borrowable(item)) {
3090
+ rc = encode(item);
3091
+ } else {
3092
+ Refs r{Py_NewRef(item), nullptr};
3093
+ rc = encode(item);
3094
+ }
3095
+ }
3096
+ } catch (const std::bad_alloc &) {
3097
+ oom = true;
3098
+ }
3099
+ Py_END_CRITICAL_SECTION();
3100
+ if (oom) {
3101
+ throw std::bad_alloc();
3102
+ }
3103
+ return rc;
3104
+ }
3105
+ #endif
3106
+
3013
3107
  // Dict subclasses as dicts (their items, as stored), as with orjson.
3014
3108
  FSJ_NOINLINE int encode_dict(PyObject *o) {
3015
3109
  if (depth >= ORJSON_MAX_DEPTH) { // empty containers count too
@@ -3027,6 +3121,15 @@ struct OrjsonEncoder {
3027
3121
  keys = g_thread_parser.encoded_keys = new (std::nothrow) EncodedKey[ENCODED_KEY_CACHE_SIZE];
3028
3122
  }
3029
3123
  int rc = 0;
3124
+ #ifdef Py_GIL_DISABLED
3125
+ if (!(opts & (OPT_NON_STR_KEYS | OPT_SORT_KEYS))) {
3126
+ rc = encode_entries_locked(o);
3127
+ if (rc == 0) {
3128
+ close('}');
3129
+ }
3130
+ return rc;
3131
+ }
3132
+ #endif
3030
3133
  // Without borrowed (Python code may run, or another thread), a snapshot
3031
3134
  // holds the keys and values.
3032
3135
  PyObject *items = borrowed ? nullptr : PyDict_Items(o);
@@ -3168,6 +3271,27 @@ struct OrjsonEncoder {
3168
3271
  }
3169
3272
  };
3170
3273
 
3274
+ // The flags of option= (None, or an int of orjson's OPT_* flags), as
3275
+ // orjson.dumps checks them.
3276
+ bool orjson_options(PyObject *option, unsigned long *opts) {
3277
+ *opts = 0;
3278
+ if (option == nullptr || option == Py_None) {
3279
+ return true;
3280
+ }
3281
+ if (!PyLong_Check(option)) {
3282
+ PyErr_SetString(PyExc_TypeError, "Invalid opts");
3283
+ return false;
3284
+ }
3285
+ long v = PyLong_AsLong(option);
3286
+ if ((v == -1 && PyErr_Occurred()) || v < 0 || (unsigned long)v > OPT_ALL) {
3287
+ PyErr_Clear();
3288
+ PyErr_SetString(PyExc_TypeError, "Invalid opts");
3289
+ return false;
3290
+ }
3291
+ *opts = (unsigned long)v;
3292
+ return true;
3293
+ }
3294
+
3171
3295
  // dumpb(obj, /, default=None, option=None), as orjson.dumps.
3172
3296
  PyObject *dumpb(PyObject *, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) {
3173
3297
  PyObject *obj = nullptr, *dflt = nullptr, *option = nullptr;
@@ -3196,18 +3320,8 @@ PyObject *dumpb(PyObject *, PyObject *const *args, Py_ssize_t nargs, PyObject *k
3196
3320
  // fails, and becomes the __cause__ of the error.
3197
3321
  e.default_fn = dflt;
3198
3322
  }
3199
- if (option != nullptr && option != Py_None) {
3200
- if (!PyLong_Check(option)) {
3201
- PyErr_SetString(PyExc_TypeError, "Invalid opts");
3202
- return nullptr;
3203
- }
3204
- long v = PyLong_AsLong(option);
3205
- if ((v == -1 && PyErr_Occurred()) || v < 0 || (unsigned long)v > OPT_ALL) {
3206
- PyErr_Clear();
3207
- PyErr_SetString(PyExc_TypeError, "Invalid opts");
3208
- return nullptr;
3209
- }
3210
- e.opts = (unsigned long)v;
3323
+ if (!orjson_options(option, &e.opts)) {
3324
+ return nullptr;
3211
3325
  }
3212
3326
  int rc;
3213
3327
  try {
@@ -3848,7 +3962,16 @@ StreamObject *stream_over(StreamObject *whole, size_t from, size_t to, bool chun
3848
3962
  return s;
3849
3963
  }
3850
3964
 
3851
- struct ParallelRun {
3965
+ // Work that pool workers can help with: work() takes pieces of it until
3966
+ // none is left. active counts the workers inside work(), guarded by the
3967
+ // pool's mutex.
3968
+ struct PoolTask {
3969
+ size_t active = 0;
3970
+ virtual void work() = 0;
3971
+ virtual ~PoolTask() = default;
3972
+ };
3973
+
3974
+ struct ParallelRun : PoolTask {
3852
3975
  StreamObject *whole;
3853
3976
  PyObject *fn; // borrowed, or nullptr
3854
3977
  std::vector<size_t> bounds; // chunk i is [bounds[i], bounds[i + 1])
@@ -3861,7 +3984,6 @@ struct ParallelRun {
3861
3984
  std::vector<Chunk> chunks;
3862
3985
  std::atomic<size_t> next{0};
3863
3986
  std::atomic<size_t> first_failure{SIZE_MAX};
3864
- size_t active = 0; // pool workers in work(), guarded by the pool's mutex
3865
3987
 
3866
3988
  void note_failure(size_t i) {
3867
3989
  size_t f = first_failure.load();
@@ -3928,7 +4050,7 @@ struct ParallelRun {
3928
4050
 
3929
4051
  // Takes chunks until none is left, letting the garbage collector of
3930
4052
  // other threads run between chunks.
3931
- void work() {
4053
+ void work() override {
3932
4054
  for (;;) {
3933
4055
  size_t i = next.fetch_add(1);
3934
4056
  if (i >= chunks.size() || i > first_failure.load()) {
@@ -3953,7 +4075,7 @@ struct WorkerPool {
3953
4075
  std::mutex mu;
3954
4076
  std::condition_variable wake; // tasks, or stopping
3955
4077
  std::condition_variable done; // a worker finished a task, or exited
3956
- std::deque<ParallelRun *> tasks;
4078
+ std::deque<PoolTask *> tasks;
3957
4079
  PyInterpreterState *interp = nullptr;
3958
4080
  size_t workers = 0;
3959
4081
  size_t ready = 0; // workers with a thread state, waiting for tasks
@@ -3971,7 +4093,7 @@ void pool_worker(WorkerPool *pool) {
3971
4093
  }
3972
4094
  pool->done.notify_all();
3973
4095
  for (;;) {
3974
- ParallelRun *run;
4096
+ PoolTask *run;
3975
4097
  {
3976
4098
  std::unique_lock<std::mutex> lk(pool->mu);
3977
4099
  pool->wake.wait(lk, [pool] { return pool->stopping || !pool->tasks.empty(); });
@@ -4072,6 +4194,31 @@ PyObject *stop_pool(PyObject *, PyObject *) {
4072
4194
  Py_RETURN_NONE;
4073
4195
  }
4074
4196
 
4197
+ // Runs task->work() in the calling thread, helped by up to nthreads - 1
4198
+ // pool workers; returns when the task is done.
4199
+ void run_on_pool(PoolTask *task, size_t nthreads) {
4200
+ WorkerPool *pool = nthreads > 1 ? get_pool(nthreads - 1) : nullptr;
4201
+ if (pool != nullptr) {
4202
+ {
4203
+ std::lock_guard<std::mutex> lk(pool->mu);
4204
+ for (size_t t = 1; t < nthreads; t++) {
4205
+ pool->tasks.push_back(task);
4206
+ }
4207
+ }
4208
+ pool->wake.notify_all();
4209
+ }
4210
+ task->work(); // the calling thread too
4211
+ if (pool != nullptr) {
4212
+ Py_BEGIN_ALLOW_THREADS
4213
+ std::unique_lock<std::mutex> lk(pool->mu);
4214
+ for (auto it = pool->tasks.begin(); it != pool->tasks.end();) {
4215
+ it = *it == task ? pool->tasks.erase(it) : it + 1;
4216
+ }
4217
+ pool->done.wait(lk, [task] { return task->active == 0; });
4218
+ Py_END_ALLOW_THREADS
4219
+ }
4220
+ }
4221
+
4075
4222
  #ifndef _WIN32
4076
4223
  // A forked child has none of the workers: forget the pool (and its mutexes,
4077
4224
  // which another thread may have held during the fork).
@@ -4120,26 +4267,7 @@ PyObject *run_stream(StreamObject *whole, PyObject *fn, size_t threads) {
4120
4267
  run.chunks.resize(run.bounds.size() - 1);
4121
4268
  // Starting a thread costs about as much as building 512 KB of documents.
4122
4269
  size_t nthreads = std::min({threads, run.chunks.size(), std::max<size_t>(whole->len >> 19, 1)});
4123
- WorkerPool *pool = nthreads > 1 ? get_pool(nthreads - 1) : nullptr;
4124
- if (pool != nullptr) {
4125
- {
4126
- std::lock_guard<std::mutex> lk(pool->mu);
4127
- for (size_t t = 1; t < nthreads; t++) {
4128
- pool->tasks.push_back(&run);
4129
- }
4130
- }
4131
- pool->wake.notify_all();
4132
- }
4133
- run.work(); // the calling thread too
4134
- if (pool != nullptr) {
4135
- Py_BEGIN_ALLOW_THREADS
4136
- std::unique_lock<std::mutex> lk(pool->mu);
4137
- for (auto it = pool->tasks.begin(); it != pool->tasks.end();) {
4138
- it = *it == &run ? pool->tasks.erase(it) : it + 1;
4139
- }
4140
- pool->done.wait(lk, [&run] { return run.active == 0; });
4141
- Py_END_ALLOW_THREADS
4142
- }
4270
+ run_on_pool(&run, nthreads);
4143
4271
  size_t f = run.first_failure.load();
4144
4272
  size_t total = 0;
4145
4273
  for (size_t i = 0; i < run.chunks.size() && i <= f; i++) {
@@ -4387,6 +4515,213 @@ PyObject *map_many(PyObject *, PyObject *args, PyObject *kwargs) {
4387
4515
  return r;
4388
4516
  }
4389
4517
 
4518
+ // ---------------------------------------------------------------------------
4519
+ // dumpb_many(records, /, default=None, option=None, *, threads=None): the
4520
+ // records as NDJSON, each as dumpb writes it, followed by a newline. With
4521
+ // threads (free-threaded Python), chunks of records are encoded at once.
4522
+ // ---------------------------------------------------------------------------
4523
+
4524
+ // Encodes items [a, b) into e.out, one per line. Returns b, or the index of
4525
+ // the record that failed, with the exception set.
4526
+ size_t encode_lines(OrjsonEncoder &e, PyObject *const *items, size_t a, size_t b) {
4527
+ for (size_t i = a; i < b; i++) {
4528
+ size_t mark = e.out.len;
4529
+ e.depth = e.default_calls = 0;
4530
+ #ifndef Py_GIL_DISABLED
4531
+ e.borrowed = true;
4532
+ #endif
4533
+ int rc = e.encode(items[i]);
4534
+ if (rc < 0 && e.restart) { // Python code may run: again, without borrowing
4535
+ e.borrowed = e.restart = false;
4536
+ e.out.len = mark;
4537
+ e.depth = e.default_calls = 0;
4538
+ rc = e.encode(items[i]);
4539
+ }
4540
+ if (rc < 0) {
4541
+ return i;
4542
+ }
4543
+ e.out.push_back('\n');
4544
+ }
4545
+ return b;
4546
+ }
4547
+
4548
+ #ifdef Py_GIL_DISABLED
4549
+ struct DumpRun : PoolTask {
4550
+ PyObject *const *items;
4551
+ PyObject *dflt; // borrowed, or nullptr
4552
+ unsigned long opts;
4553
+ std::vector<size_t> bounds; // chunk i is items [bounds[i], bounds[i + 1])
4554
+ struct Chunk {
4555
+ std::unique_ptr<OrjsonEncoder> e; // its output
4556
+ PyObject *type = nullptr, *value = nullptr, *tb = nullptr; // its error
4557
+ };
4558
+ std::vector<Chunk> chunks;
4559
+ std::atomic<size_t> next{0};
4560
+ std::atomic<size_t> first_failure{SIZE_MAX};
4561
+ // Output of the chunks done so far, to size the buffers of the next ones
4562
+ // (growing a buffer copies it).
4563
+ std::atomic<size_t> done_bytes{0};
4564
+ std::atomic<size_t> done_records{0};
4565
+
4566
+ // On an attached thread.
4567
+ void run_chunk(size_t i) {
4568
+ Chunk &c = chunks[i];
4569
+ bool failed;
4570
+ try {
4571
+ c.e.reset(new OrjsonEncoder()); // uses this thread's key cache
4572
+ c.e->default_fn = dflt;
4573
+ c.e->opts = opts;
4574
+ size_t records = bounds[i + 1] - bounds[i];
4575
+ size_t seen = done_records.load(std::memory_order_relaxed);
4576
+ if (seen > 0) {
4577
+ size_t per_record = done_bytes.load(std::memory_order_relaxed) / seen + 1;
4578
+ c.e->out.reserve(per_record * records + per_record * records / 8);
4579
+ }
4580
+ failed = encode_lines(*c.e, items, bounds[i], bounds[i + 1]) < bounds[i + 1];
4581
+ if (!failed) {
4582
+ done_bytes.fetch_add(c.e->out.len, std::memory_order_relaxed);
4583
+ done_records.fetch_add(records, std::memory_order_relaxed);
4584
+ }
4585
+ } catch (const std::bad_alloc &) {
4586
+ PyErr_NoMemory();
4587
+ failed = true;
4588
+ }
4589
+ if (failed) {
4590
+ PyErr_Fetch(&c.type, &c.value, &c.tb);
4591
+ size_t f = first_failure.load();
4592
+ while (i < f && !first_failure.compare_exchange_weak(f, i)) {
4593
+ }
4594
+ }
4595
+ }
4596
+
4597
+ void work() override {
4598
+ for (;;) {
4599
+ size_t i = next.fetch_add(1);
4600
+ if (i >= chunks.size() || i > first_failure.load()) {
4601
+ return;
4602
+ }
4603
+ run_chunk(i);
4604
+ Py_BEGIN_ALLOW_THREADS
4605
+ Py_END_ALLOW_THREADS
4606
+ }
4607
+ }
4608
+ };
4609
+
4610
+ // Copies the output of each chunk into place, on several threads too (one
4611
+ // thread copies about 10 GB/s).
4612
+ struct CopyRun : PoolTask {
4613
+ DumpRun *run;
4614
+ char *dst;
4615
+ std::vector<size_t> offsets;
4616
+ std::atomic<size_t> next{0};
4617
+
4618
+ void work() override {
4619
+ for (;;) {
4620
+ size_t i = next.fetch_add(1);
4621
+ if (i >= run->chunks.size()) {
4622
+ return;
4623
+ }
4624
+ const OutBuf &o = run->chunks[i].e->out;
4625
+ memcpy(dst + offsets[i], o.buf, o.len);
4626
+ }
4627
+ }
4628
+ };
4629
+
4630
+ // The records on several threads, or nullptr with an exception.
4631
+ PyObject *dumpb_many_threads(PyObject *const *items, size_t n, PyObject *dflt,
4632
+ unsigned long opts, size_t threads) {
4633
+ DumpRun run;
4634
+ run.items = items;
4635
+ run.dflt = dflt;
4636
+ run.opts = opts;
4637
+ size_t nchunks = std::min(threads * 8, n);
4638
+ for (size_t k = 0; k <= nchunks; k++) {
4639
+ run.bounds.push_back(n * k / nchunks);
4640
+ }
4641
+ run.chunks.resize(nchunks);
4642
+ run_on_pool(&run, std::min(threads, nchunks));
4643
+ size_t f = run.first_failure.load();
4644
+ PyObject *result = nullptr;
4645
+ if (f == SIZE_MAX) {
4646
+ CopyRun copy;
4647
+ copy.run = &run;
4648
+ size_t total = 0;
4649
+ for (auto &c : run.chunks) {
4650
+ copy.offsets.push_back(total);
4651
+ total += c.e->out.len;
4652
+ }
4653
+ result = PyBytes_FromStringAndSize(nullptr, Py_ssize_t(total));
4654
+ if (result != nullptr) {
4655
+ copy.dst = PyBytes_AS_STRING(result);
4656
+ run_on_pool(&copy, std::min(threads, nchunks));
4657
+ }
4658
+ }
4659
+ // The error of the first record that failed, as without threads.
4660
+ for (size_t i = 0; i < run.chunks.size(); i++) {
4661
+ DumpRun::Chunk &c = run.chunks[i];
4662
+ if (i == f) {
4663
+ PyErr_Restore(c.type, c.value, c.tb);
4664
+ } else {
4665
+ Py_XDECREF(c.type);
4666
+ Py_XDECREF(c.value);
4667
+ Py_XDECREF(c.tb);
4668
+ }
4669
+ }
4670
+ return result;
4671
+ }
4672
+ #endif
4673
+
4674
+ PyObject *dumpb_many(PyObject *, PyObject *args, PyObject *kwargs) {
4675
+ Py_ssize_t threads;
4676
+ bool given;
4677
+ PyObject *rest = take_threads(kwargs, &threads, &given);
4678
+ if (rest == nullptr && PyErr_Occurred()) {
4679
+ return nullptr;
4680
+ }
4681
+ static const char *kwlist[] = {"", "default", "option", nullptr};
4682
+ PyObject *records, *dflt = nullptr, *option = nullptr;
4683
+ int ok = PyArg_ParseTupleAndKeywords(args, rest, "O|OO:dumpb_many",
4684
+ const_cast<char **>(kwlist), &records, &dflt, &option);
4685
+ Py_XDECREF(rest);
4686
+ unsigned long opts;
4687
+ if (!ok || !orjson_options(option, &opts)) {
4688
+ return nullptr;
4689
+ }
4690
+ if (opts & OPT_INDENT_2) {
4691
+ PyErr_SetString(PyExc_ValueError, "dumpb_many writes one record per line: OPT_INDENT_2 is not supported");
4692
+ return nullptr;
4693
+ }
4694
+ // A snapshot of the records (a list could change while we encode it).
4695
+ PyObject *tuple = PySequence_Tuple(records);
4696
+ if (tuple == nullptr) {
4697
+ return nullptr;
4698
+ }
4699
+ PyObject *const *items = &PyTuple_GET_ITEM(tuple, 0);
4700
+ size_t n = size_t(PyTuple_GET_SIZE(tuple));
4701
+ PyObject *result = nullptr;
4702
+ #ifdef Py_GIL_DISABLED
4703
+ if (given && threads > 1 && n > 1) {
4704
+ result = dumpb_many_threads(items, n, dflt, opts, size_t(threads));
4705
+ Py_DECREF(tuple);
4706
+ return result;
4707
+ }
4708
+ #else
4709
+ (void)given;
4710
+ #endif
4711
+ OrjsonEncoder e;
4712
+ e.default_fn = dflt;
4713
+ e.opts = opts;
4714
+ try {
4715
+ if (encode_lines(e, items, 0, n) == n) {
4716
+ result = e.out.take_bytes();
4717
+ }
4718
+ } catch (const std::bad_alloc &) {
4719
+ PyErr_NoMemory();
4720
+ }
4721
+ Py_DECREF(tuple);
4722
+ return result;
4723
+ }
4724
+
4390
4725
  // Benchmarking helper: run simdjson without building Python objects.
4391
4726
  PyObject *parse_only(PyObject *, PyObject *arg) {
4392
4727
  if (!PyBytes_Check(arg)) {
@@ -4433,9 +4768,15 @@ PyMethodDef methods[] = {
4433
4768
  #ifdef Py_GIL_DISABLED
4434
4769
  {"_stop_pool", stop_pool, METH_NOARGS, "Stop the worker threads (at exit)."},
4435
4770
  #endif
4771
+ {"dumpb_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(dumpb_many)),
4772
+ METH_VARARGS | METH_KEYWORDS,
4773
+ "dumpb_many(records, /, default=None, option=None, *, threads=None)\n--\n\n"
4774
+ "The records as NDJSON bytes: each as dumpb writes it, then a newline.\n"
4775
+ "With threads (free-threaded Python), the records are encoded on several threads."},
4436
4776
  {"map_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(map_many)),
4437
4777
  METH_VARARGS | METH_KEYWORDS,
4438
- "map_many(fn, data, *, format, batch_size, threads, lazy): the list of fn(doc) for the "
4778
+ "map_many(fn, data, *, format, batch_size, threads, lazy, fields, default): the list of "
4779
+ "fn(doc) for the "
4439
4780
  "documents of data, with fn called in the threads."},
4440
4781
  {"parse", parse, METH_O,
4441
4782
  "Parse JSON lazily: objects and arrays are returned as read-only views "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -42,7 +42,8 @@ function is faster still. It also reads streams of documents (NDJSON, JSON
42
42
  Lines), on many threads with free-threaded Python, and can extract only the
43
43
  fields you need from each document. It writes JSON in two ways: `dumps` returns the same `str` as
44
44
  `json.dumps`, and `dumpb` returns the same `bytes` as `orjson.dumps`, as fast
45
- as orjson.
45
+ as orjson; `dumpb_many` writes NDJSON, on many threads with free-threaded
46
+ Python.
46
47
 
47
48
  With pip:
48
49
 
@@ -158,6 +159,30 @@ serialize dataclasses, `datetime`, `date`, `time`, `UUID`, numpy arrays or
158
159
  `OPT_PASSTHROUGH_DATACLASS` and `OPT_PASSTHROUGH_DATETIME` were set, and the
159
160
  options that concern only these types are accepted and have no effect.
160
161
 
162
+ ### `dumpb_many`: NDJSON, on several threads
163
+
164
+ ```python
165
+ data = fastsimdjson.dumpb_many(records) # b'{...}\n{...}\n'
166
+ data = fastsimdjson.dumpb_many(records, threads=16) # free-threaded Python
167
+ ```
168
+
169
+ `dumpb_many(records, default=None, option=None, *, threads=None)` returns
170
+ the records (a list, a tuple or any iterable) as NDJSON `bytes`: each record
171
+ as `dumpb` writes it, followed by a newline, so that the output reads back
172
+ with `loads_many(data, format="lines")`. `default` and `option` are those of
173
+ `dumpb`, except `OPT_INDENT_2`, which would spread a record over several
174
+ lines (`ValueError`); `OPT_APPEND_NEWLINE` changes nothing.
175
+
176
+ On free-threaded Python, `threads=N` writes chunks of records on N threads at
177
+ once (`threads=0` or `None`: one per processor), with the worker threads of
178
+ `map_many`. The output and the errors are those of one thread: when records
179
+ cannot be serialized, the error of the first of them is raised (`default` may
180
+ then have been called on later records, in other threads, and it must be
181
+ safe to call from several threads at once). With the GIL, the calling thread
182
+ does all the work. Like `dumpb`, `dumpb_many` does not crash when other
183
+ threads change the records meanwhile, but the output then reflects some of
184
+ the changes and not others.
185
+
161
186
  ### Files
162
187
 
163
188
  ```python
@@ -380,7 +405,7 @@ geometric mean over the 22 files (higher is better).
380
405
 
381
406
  <picture>
382
407
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads_dark.png">
383
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
408
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_loads.png" width="80%" alt="Parsing JSON in Python: fastsimdjson loads 0.78 GB/s, orjson 0.60, msgspec 0.53, jiter 0.48, jiter without its string cache 0.44, pysimdjson 0.44, cysimdjson 0.43, ujson 0.37, python-rapidjson 0.24, simplejson 0.23, json 0.22">
384
409
  </picture>
385
410
 
386
411
  | parser | GB/s | vs `json.loads` |
@@ -391,7 +416,8 @@ geometric mean over the 22 files (higher is better).
391
416
  | ujson 6.0.0 | 0.37 | 1.66× |
392
417
  | cysimdjson 26.27 | 0.43 | 1.96× |
393
418
  | pysimdjson 7.0.2 | 0.44 | 1.98× |
394
- | jiter 0.17.0 | 0.48 | 2.21× |
419
+ | jiter 0.17.0 (no string cache) | 0.44 | 2.00× |
420
+ | jiter 0.17.0 (string cache, the default) | 0.48 | 2.21× |
395
421
  | msgspec 0.22.0 | 0.53 | 2.42× |
396
422
  | orjson 3.12.0 | 0.60 | 2.74× |
397
423
  | **fastsimdjson `loads`** | **0.78** | **3.53×** |
@@ -407,7 +433,7 @@ the collector enabled and 1.22× with it disabled (geometric means); without
407
433
  the collector, `canada.json`, `mesh.json` and `numbers.json` are ties. yyjson 4.0.6 is left out: it
408
434
  returns wrong strings for non-ASCII text. jiter (the parser of pydantic)
409
435
  caches strings by default; turning the cache off (`cache_mode=False`) makes it
410
- slower here (0.44 GB/s).
436
+ slower here.
411
437
 
412
438
  Parsing is no longer the bottleneck. simdjson alone parses these files at
413
439
  3.1 GB/s. It accounts for about a third of the time of `loads`; the rest goes
@@ -422,12 +448,14 @@ and the screen name of the 100 statuses of `twitter.json`:
422
448
 
423
449
  <picture>
424
450
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse_dark.png">
425
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, json 3922">
451
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_parse.png" width="80%" alt="Reading part of twitter.json: fastsimdjson parse 158 µs, pysimdjson 179, cysimdjson 229, msgspec 336, fastsimdjson loads 861, orjson 1009, jiter 1281, jiter without its string cache 1592, json 3922">
426
452
  </picture>
427
453
 
428
454
  | method | µs |
429
455
  |---|---:|
430
456
  | `json.loads` | 3922 |
457
+ | jiter (no string cache) | 1592 |
458
+ | jiter (string cache) | 1281 |
431
459
  | orjson | 1009 |
432
460
  | fastsimdjson `loads` | 861 |
433
461
  | msgspec (typed `Struct`) | 336 |
@@ -481,6 +509,18 @@ times faster than msgspec and 10 times faster than `json`. The comparison was
481
509
  run on a processor with AVX-512, which `dumpb` and orjson both use to escape
482
510
  strings; other x64 processors use SSE2 and ARM processors NEON.
483
511
 
512
+ ### Writing NDJSON with `dumpb_many`
513
+
514
+ The 593,945 events of two hours of [GH Archive](https://www.gharchive.org/)
515
+ (1.87 GB of NDJSON), written back as NDJSON from Python objects. With the
516
+ GIL (Python 3.14.6), one thread: `orjson.dumps` on each record, joined, runs
517
+ at 0.94 GB/s, `dumpb` on each record at 1.07 GB/s, and `dumpb_many` at
518
+ 1.41 GB/s. On free-threaded Python 3.14.6t, GB/s:
519
+
520
+ | threads | 1 | 8 | 32 | 64 |
521
+ |---|---:|---:|---:|---:|
522
+ | `dumpb_many(records, threads=N)` | 1.3 | 6.0 | 11 | 12 |
523
+
484
524
  ### Streams with `loads_many`
485
525
 
486
526
  20 MB of NDJSON (5268 objects and arrays, one per line), made from the same
@@ -488,12 +528,14 @@ files; best of three runs:
488
528
 
489
529
  <picture>
490
530
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson_dark.png">
491
- <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, json per line 0.12">
531
+ <img src="https://raw.githubusercontent.com/simdjson/fastpysimdjson/main/doc/perf_ndjson.png" width="80%" alt="Reading NDJSON: fastsimdjson parse_many 0.94 GB/s, loads_many 0.38, msgspec decode_lines 0.37, fastsimdjson loads per line 0.33, msgspec decode per line 0.27, orjson per line 0.26, jiter per line 0.22, jiter without its string cache per line 0.20, json per line 0.12">
492
532
  </picture>
493
533
 
494
534
  | method | ms | GB/s |
495
535
  |---|---:|---:|
496
536
  | `json.loads` on each line | 169 | 0.12 |
537
+ | jiter on each line (no string cache) | 98 | 0.20 |
538
+ | jiter on each line (string cache) | 90 | 0.22 |
497
539
  | orjson on each line | 77 | 0.26 |
498
540
  | msgspec `decode` on each line | 74 | 0.27 |
499
541
  | fastsimdjson `loads` on each line | 60 | 0.33 |
@@ -435,3 +435,115 @@ def test_thread_exit_releases_caches():
435
435
  t.start()
436
436
  t.join()
437
437
  assert sys.getrefcount(key) == before
438
+
439
+
440
+ # dumpb_many: each record as dumpb writes it, then a newline.
441
+
442
+ def _lines(records, **kw):
443
+ return b"".join(fastsimdjson.dumpb(r, **kw) + b"\n" for r in records)
444
+
445
+
446
+ def _error(fn):
447
+ try:
448
+ fn()
449
+ except Exception as e: # noqa: BLE001
450
+ return type(e), str(e), type(e.__cause__)
451
+ return None
452
+
453
+
454
+ @pytest.mark.parametrize("seed", range(10))
455
+ def test_dumpb_many_random(seed):
456
+ rnd = random.Random(seed)
457
+ records = [random_value(rnd) for _ in range(rnd.randrange(1, 400))]
458
+ expected = _lines(records)
459
+ for threads in (None, 1, 4, 7):
460
+ kw = {} if threads is None else {"threads": threads}
461
+ assert fastsimdjson.dumpb_many(records, **kw) == expected
462
+ assert fastsimdjson.dumpb_many(tuple(records), **kw) == expected
463
+ assert fastsimdjson.dumpb_many(iter(records), **kw) == expected
464
+ # The output is NDJSON that reads back.
465
+ assert list(fastsimdjson.loads_many(expected, format="lines")) == \
466
+ [fastsimdjson.loads(fastsimdjson.dumpb(r)) for r in records]
467
+
468
+
469
+ def test_dumpb_many_options():
470
+ records = [{"b": 1, "a": {1: 2}}, {"z": [3]}] * 50
471
+ for option in (4 | 32, 32): # OPT_NON_STR_KEYS | OPT_SORT_KEYS; OPT_SORT_KEYS
472
+ expected = _error(lambda: _lines(records, option=option)) or _lines(records, option=option)
473
+ for threads in (1, 4):
474
+ got = _error(lambda: fastsimdjson.dumpb_many(records, option=option, threads=threads)) or \
475
+ fastsimdjson.dumpb_many(records, None, option, threads=threads)
476
+ assert got == expected
477
+ # One newline per record, with or without OPT_APPEND_NEWLINE.
478
+ assert fastsimdjson.dumpb_many([1, 2], option=fastsimdjson.OPT_APPEND_NEWLINE) == b"1\n2\n"
479
+ assert fastsimdjson.dumpb_many([]) == b""
480
+ assert fastsimdjson.dumpb_many([], threads=4) == b""
481
+ with pytest.raises(ValueError):
482
+ fastsimdjson.dumpb_many([1], option=fastsimdjson.OPT_INDENT_2)
483
+ with pytest.raises(TypeError, match="Invalid opts"):
484
+ fastsimdjson.dumpb_many([1], option="x")
485
+ with pytest.raises(TypeError):
486
+ fastsimdjson.dumpb_many(5)
487
+ with pytest.raises(TypeError):
488
+ fastsimdjson.dumpb_many()
489
+ with pytest.raises(ValueError):
490
+ fastsimdjson.dumpb_many([1], threads=-1)
491
+
492
+
493
+ class _Thing:
494
+ def __init__(self, n):
495
+ self.n = n
496
+
497
+
498
+ def test_dumpb_many_default_and_errors():
499
+ records = [{"i": i, "t": _Thing(i)} for i in range(3000)]
500
+
501
+ def default(o):
502
+ if isinstance(o, _Thing):
503
+ if o.n in (1700, 2500):
504
+ raise ValueError(f"bad {o.n}")
505
+ return o.n * 2
506
+ raise TypeError
507
+
508
+ good = records[:1700]
509
+ for threads in (None, 1, 4, 16):
510
+ kw = {} if threads is None else {"threads": threads}
511
+ assert fastsimdjson.dumpb_many(good, default=default, **kw) == _lines(good, default=default)
512
+ # The error of the first record that fails, as dumpb raises it (with
513
+ # default's exception as its cause).
514
+ got = _error(lambda: fastsimdjson.dumpb_many(records, default, **kw))
515
+ assert got == _error(lambda: fastsimdjson.dumpb(records[1700], default=default))
516
+ assert got[2] is ValueError
517
+ bad = [1, 2, {"x": object()}, 3, [object()]]
518
+ assert _error(lambda: fastsimdjson.dumpb_many(bad, **kw)) == \
519
+ _error(lambda: fastsimdjson.dumpb(bad[2]))
520
+
521
+
522
+ def test_dumpb_many_under_mutation():
523
+ # Another thread changes the records while they are written: the output
524
+ # is still one valid document per record (free-threaded Python).
525
+ import threading
526
+
527
+ records = [{"i": i, "l": [i, "é", {"k": i}], "d": {"x": [1, 2]}} for i in range(3000)]
528
+ stop = threading.Event()
529
+
530
+ def mutate():
531
+ rnd = random.Random(0)
532
+ while not stop.is_set():
533
+ r = records[rnd.randrange(len(records))]
534
+ r["l"].append(rnd.random())
535
+ if len(r["l"]) > 6:
536
+ r["l"].clear()
537
+ r["d"] = {"y": "é" * rnd.randrange(4), "z": [None]}
538
+ r.pop("i", None) if rnd.random() < 0.5 else r.update(i=rnd.randrange(9))
539
+
540
+ t = threading.Thread(target=mutate)
541
+ t.start()
542
+ try:
543
+ for _ in range(20):
544
+ out = fastsimdjson.dumpb_many(records, threads=8)
545
+ docs = list(fastsimdjson.loads_many(out, format="lines"))
546
+ assert len(docs) == len(records)
547
+ finally:
548
+ stop.set()
549
+ t.join()
File without changes
File without changes
File without changes
File without changes