Skip to content

Commit 34038ba

Browse files
authored
Merge pull request #34 from lpgauth/compiled-pointers
Add compiled pointers and fused parse_get_many_nil
2 parents 03ac63c + 2b03fb1 commit 34038ba

8 files changed

Lines changed: 398 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ Torque is a high-performance JSON library for Elixir using Rustler NIFs backed b
7272

7373
1. **Parse + Get**`parse/1` returns an opaque reference to a parsed document (`sonic_rs::Value`). `get/2,3` extracts fields by JSON Pointer (RFC 6901) path via `value_to_term`. `get_many/2` extracts multiple fields in a single NIF call. Ideal when only a subset of fields is needed.
7474

75-
2. **Full decode**`decode/1` builds Erlang terms directly during the SIMD parse by implementing sonic-rs's native `JsonVisitor` (`native_decode.rs`): single pass, no intermediate `Value`, zero-copy sub-binaries for unescaped strings.
75+
2. **Compiled pointers** — for a *fixed* set of paths extracted from every document, `compile_pointers/2` pre-parses the pointer strings once into a `CompiledPaths` resource (`PathSeg::Key` / `PathSeg::Num{idx,key}`, with `~`-unescaping and array-index-vs-object-key resolution done up front). `parse_get_many_nil/2` then fuses the DOM parse and extraction into one NIF call (no document handle, no second boundary crossing), returning `{:ok, values}` with `nil` for missing/`null`. The handle carries the `unique_keys` lookup strategy. ~1.5× faster end-to-end than `parse/2` + `get_many_nil/2` on a typical field set; `get_many_nil/2` also accepts a compiled handle to query an already-parsed doc. Note: a lazy single-pass approach (sonic-rs `get_many` over a `PointerTree`) was measured ~*slower* here — per-call `PointerTree` (HashMap + `FastStr`) construction dominates — so the DOM is the right structure for this small-doc / many-short-paths workload.
76+
77+
3. **Full decode**`decode/1` builds Erlang terms directly during the SIMD parse by implementing sonic-rs's native `JsonVisitor` (`native_decode.rs`): single pass, no intermediate `Value`, zero-copy sub-binaries for unescaped strings.
7678

7779
### Encoding
7880

@@ -98,8 +100,8 @@ Inputs larger than 20 KB are automatically dispatched to dirty CPU schedulers to
98100

99101
- `lib/torque.ex` — public API with `@doc`, typespecs, dirty scheduler dispatch
100102
- `lib/torque/native.ex` — RustlerPrecompiled NIF stubs (set `TORQUE_BUILD=true` to compile from source)
101-
- `native/torque_nif/src/lib.rs` — NIF registration, `ParsedDocument` resource
102-
- `native/torque_nif/src/decoder.rs` — parse, get, get_many, decode NIFs
103+
- `native/torque_nif/src/lib.rs` — NIF registration, `ParsedDocument` + `CompiledPaths` (`PathSeg`) resources
104+
- `native/torque_nif/src/decoder.rs` — parse, get, get_many, get_many_nil, decode NIFs; compiled-pointer + fused `parse_get_many_nil` path
103105
- `native/torque_nif/src/native_decode.rs` — fused decoder; builds terms during the SIMD parse via sonic-rs's `JsonVisitor`
104106
- `native/torque_nif/src/encoder.rs` — direct term-walking JSON encoder
105107
- `native/torque_nif/src/types.rs` — sonic_rs Value → Erlang term conversion (used by get/get_many)

README.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Torque provides the fastest JSON encoding and decoding available in the BEAM eco
1010
- Ultra-low memory encoder (64 B per encode vs ~4 KB for OTP `json`/jason)
1111
- Parse-then-get API for selective field extraction via JSON Pointer (RFC 6901)
1212
- Batch field extraction (`get_many/2`) with single NIF call
13+
- Pre-compiled pointers with fused parse + extract (`parse_get_many_nil/2`)
1314
- Automatic dirty CPU scheduler dispatch for inputs larger than 20 KB
1415
- jiffy-compatible `{proplist}` encoding
1516

@@ -78,6 +79,24 @@ for faster field lookups (uses sonic-rs internal indexing instead of linear scan
7879
{:ok, doc} = Torque.parse(json, unique_keys: true)
7980
```
8081

82+
### Compiled Pointers
83+
84+
When the same fixed set of paths is extracted from every document, compile the
85+
pointers once and reuse the handle. `parse_get_many_nil/2` then fuses the parse
86+
and extraction into a single NIF call, skipping all per-request path parsing —
87+
roughly 1.5× faster end-to-end than `parse/2` + `get_many_nil/2`.
88+
89+
```elixir
90+
# Once, at startup (e.g. a module attribute or :persistent_term):
91+
pointers = Torque.compile_pointers(["/id", "/site/domain", "/imp/0/banner/w"], unique_keys: true)
92+
93+
# Per document — parse + extract in one call:
94+
{:ok, ["req-1", "example.com", 300]} = Torque.parse_get_many_nil(json, pointers)
95+
```
96+
97+
Missing fields and JSON `null` both become `nil`. The handle also works with an
98+
already-parsed document via `Torque.get_many_nil(doc, pointers)`.
99+
81100
### Encoding
82101

83102
```elixir
@@ -99,17 +118,19 @@ json = Torque.encode_to_iodata(%{id: "abc"})
99118

100119
| Function | Description |
101120
|----------|-------------|
121+
| `Torque.compile_pointers(paths, opts)` | Pre-compile a fixed path set into a reusable handle |
102122
| `Torque.decode(binary)` | Decode JSON to Elixir terms |
103123
| `Torque.decode!(binary)` | Decode JSON, raising on error |
104-
| `Torque.parse(binary, opts)` | Parse JSON into opaque document reference |
124+
| `Torque.encode(term)` | Encode term to JSON binary |
125+
| `Torque.encode!(term)` | Encode term, raising on error |
126+
| `Torque.encode_to_iodata(term)` | Encode term, returns binary directly (fastest) |
105127
| `Torque.get(doc, path)` | Extract field by JSON Pointer path |
106128
| `Torque.get(doc, path, default)` | Extract field with default for missing paths |
107129
| `Torque.get_many(doc, paths)` | Extract multiple fields in one NIF call |
108130
| `Torque.get_many_nil(doc, paths)` | Extract multiple fields, `nil` for missing |
109131
| `Torque.length(doc, path)` | Return length of array at path |
110-
| `Torque.encode(term)` | Encode term to JSON binary |
111-
| `Torque.encode!(term)` | Encode term, raising on error |
112-
| `Torque.encode_to_iodata(term)` | Encode term, returns binary directly (fastest) |
132+
| `Torque.parse(binary, opts)` | Parse JSON into opaque document reference |
133+
| `Torque.parse_get_many_nil(binary, pointers)` | Fused parse + extract of compiled pointers in one NIF call |
113134

114135
## Type Conversion
115136

@@ -151,9 +172,9 @@ Functions return `{:error, reason}` tuples (or raise `ArgumentError` for bang/io
151172

152173
| Atom | Returned by | Meaning |
153174
|------|-------------|---------|
154-
| `:nesting_too_deep` | `decode/1`, `parse/1`, `get/2`, `get_many/2` | Document exceeds 128 nesting levels |
175+
| `:nesting_too_deep` | `decode/1`, `parse/1`, `get/2`, `get_many/2`, `parse_get_many_nil/2` | Document exceeds 128 nesting levels |
155176

156-
`parse/1` and `decode/1` also return `{:error, binary}` with a message from sonic-rs for malformed JSON.
177+
`parse/1`, `decode/1`, and `parse_get_many_nil/2` also return `{:error, binary}` with a message from sonic-rs for malformed JSON.
157178

158179
### Encode
159180

lib/torque.ex

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ defmodule Torque do
99
by JSON Pointer (RFC 6901) paths without materializing the full
1010
Elixir term tree. Ideal when only a subset of fields is needed.
1111
12+
* **Compiled pointers** — when the same fixed set of paths is extracted
13+
from every document, `compile_pointers/2` pre-parses the paths once and
14+
`parse_get_many_nil/2` fuses the parse and extraction into a single NIF
15+
call. Skips all per-request path parsing — roughly 1.5× faster end-to-end
16+
than `parse/2` + `get_many_nil/2`.
17+
1218
* **Full decode** — `decode/1` converts an entire JSON binary into
1319
Elixir terms in one pass.
1420
@@ -41,6 +47,13 @@ defmodule Torque do
4147

4248
@timeslice_bytes 20_480
4349

50+
@typedoc """
51+
An opaque handle to a set of pre-compiled JSON Pointer paths, returned by
52+
`compile_pointers/2`. Pass it to `parse_get_many_nil/2` or `get_many_nil/2`
53+
in place of a path list to skip per-call path parsing.
54+
"""
55+
@opaque pointers :: reference()
56+
4457
# --- Decoding ---
4558

4659
@doc """
@@ -289,18 +302,102 @@ defmodule Torque do
289302
Faster than `get_many/2` when you don't need to distinguish between
290303
missing fields and null values, as it avoids allocating wrapper tuples.
291304
305+
Accepts either a list of JSON Pointer path strings or a `t:pointers/0` handle
306+
built by `compile_pointers/2`. The compiled form skips all per-call path
307+
parsing and is the recommended option for a fixed, repeatedly-queried path
308+
set.
309+
292310
## Examples
293311
294312
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":null}))
295313
iex> Torque.get_many_nil(doc, ["/a", "/b", "/c"])
296314
[1, nil, nil]
315+
316+
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":null}))
317+
iex> ptrs = Torque.compile_pointers(["/a", "/b", "/c"])
318+
iex> Torque.get_many_nil(doc, ptrs)
319+
[1, nil, nil]
297320
"""
298321
@doc group: :parse_get
299-
@spec get_many_nil(reference(), [binary()]) :: [term()]
322+
@spec get_many_nil(reference(), [binary()] | pointers()) :: [term()]
300323
def get_many_nil(doc, paths) when is_reference(doc) and is_list(paths) do
301324
Torque.Native.get_many_nil(doc, paths)
302325
end
303326

327+
def get_many_nil(doc, pointers) when is_reference(doc) and is_reference(pointers) do
328+
Torque.Native.get_many_nil_compiled(doc, pointers)
329+
end
330+
331+
@doc """
332+
Pre-compiles a list of JSON Pointer paths into a reusable handle.
333+
334+
Workloads that parse many documents and extract the *same* fixed set of fields
335+
re-split and unescape those pointer strings on every call — wasted work, since
336+
they never change. `compile_pointers/2` does it once and returns an opaque
337+
`t:pointers/0` handle that `parse_get_many_nil/2` and `get_many_nil/2` accept
338+
in place of a path list, eliminating all per-call path parsing (≈2× faster
339+
extraction on a typical field set).
340+
341+
Compile once at startup (e.g. into a module attribute or `:persistent_term`)
342+
and reuse the handle for every document.
343+
344+
## Options
345+
346+
* `:unique_keys` — when `true`, object key lookups use a forward scan that
347+
stops at the first match (faster). Defaults to `false` (reverse scan,
348+
last-value-wins for duplicate keys), matching `parse/2`. Safe to enable
349+
when keys are known to be unique.
350+
351+
Extraction results are returned in the same order as `paths`.
352+
353+
## Examples
354+
355+
iex> ptrs = Torque.compile_pointers(["/a", "/b/0"], unique_keys: true)
356+
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":[2,3]}))
357+
iex> Torque.get_many_nil(doc, ptrs)
358+
[1, 2]
359+
"""
360+
@doc group: :parse_get
361+
@spec compile_pointers([binary()], keyword()) :: pointers()
362+
def compile_pointers(paths, opts \\ []) when is_list(paths) do
363+
Torque.Native.compile_paths(paths, Keyword.get(opts, :unique_keys, false))
364+
end
365+
366+
@doc """
367+
Parses a JSON binary and extracts pre-compiled pointers in a single NIF call.
368+
369+
Fuses `parse/2` and `get_many_nil/2` for the common parse-once-extract-once
370+
case: it parses the document, extracts each compiled pointer, and returns the
371+
values — without materializing a reusable document handle or crossing the NIF
372+
boundary twice. Missing fields and JSON `null` both become `nil`. The lookup
373+
strategy (`:unique_keys`) is taken from the `t:pointers/0` handle.
374+
375+
Returns `{:ok, values}` (in the same order as the paths given to
376+
`compile_pointers/2`) or `{:error, reason}` if the JSON is malformed.
377+
Automatically uses a dirty CPU scheduler for inputs larger than 20 KB.
378+
379+
## Examples
380+
381+
iex> ptrs = Torque.compile_pointers(["/id", "/site/domain", "/missing"])
382+
iex> Torque.parse_get_many_nil(~s({"id":"x","site":{"domain":"e.com"}}), ptrs)
383+
{:ok, ["x", "e.com", nil]}
384+
385+
iex> ptrs = Torque.compile_pointers(["/a"])
386+
iex> match?({:error, _}, Torque.parse_get_many_nil("not json", ptrs))
387+
true
388+
"""
389+
@doc group: :parse_get
390+
@spec parse_get_many_nil(binary(), pointers()) ::
391+
{:ok, [term()]} | {:error, binary() | :nesting_too_deep}
392+
def parse_get_many_nil(json, pointers)
393+
when is_binary(json) and is_reference(pointers) and byte_size(json) > @timeslice_bytes do
394+
Torque.Native.parse_get_many_nil_dirty(json, pointers)
395+
end
396+
397+
def parse_get_many_nil(json, pointers) when is_binary(json) and is_reference(pointers) do
398+
Torque.Native.parse_get_many_nil(json, pointers)
399+
end
400+
304401
@doc """
305402
Extracts multiple values from a parsed document with per-path defaults.
306403

lib/torque/native.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,9 @@ defmodule Torque.Native do
3838
def encode(_term), do: :erlang.nif_error(:nif_not_loaded)
3939
def encode_iodata(_term), do: :erlang.nif_error(:nif_not_loaded)
4040
def get_many_nil(_doc, _paths), do: :erlang.nif_error(:nif_not_loaded)
41+
def compile_paths(_paths, _unique_keys), do: :erlang.nif_error(:nif_not_loaded)
42+
def get_many_nil_compiled(_doc, _compiled), do: :erlang.nif_error(:nif_not_loaded)
43+
def parse_get_many_nil(_json, _compiled), do: :erlang.nif_error(:nif_not_loaded)
44+
def parse_get_many_nil_dirty(_json, _compiled), do: :erlang.nif_error(:nif_not_loaded)
4145
def array_length(_doc, _path), do: :erlang.nif_error(:nif_not_loaded)
4246
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Torque.MixProject do
22
use Mix.Project
33

4-
@version "0.2.3"
4+
@version "0.2.4"
55
@source_url "https://github.com/lpgauth/torque"
66

77
def project do

native/torque_nif/src/decoder.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,158 @@ fn decode_dirty<'a>(env: Env<'a>, json: Binary<'a>) -> Term<'a> {
297297
native_decode::decode_to_term(env, input_term, json.as_slice())
298298
}
299299

300+
// --- Pre-compiled pointers + fused parse/extract ---
301+
//
302+
// The common parse-once-extract-once workload uses a *fixed* set of JSON
303+
// Pointer paths known at startup. Compiling those paths once (segment split,
304+
// `~`-unescape, index-vs-key classification) lets the per-request call skip all
305+
// per-path string work — roughly halving extraction time — and fusing the parse
306+
// and the extraction into one NIF call avoids materializing a document handle.
307+
use crate::{CompiledPaths, PathSeg};
308+
309+
/// Pre-split a single JSON Pointer into segments. A numeric segment is stored as
310+
/// `Num`, keeping both the parsed index and the literal key so the lookup can
311+
/// pick the right interpretation per node (array index vs. object key) —
312+
/// matching the runtime behaviour of `pointer_lookup`.
313+
fn compile_one(path: &str) -> Vec<PathSeg> {
314+
let mut segs = Vec::new();
315+
if path.len() <= 1 {
316+
return segs;
317+
}
318+
for segment in path[1..].split('/') {
319+
let b = segment.as_bytes();
320+
let key = if segment.contains('~') {
321+
segment.replace("~1", "/").replace("~0", "~")
322+
} else {
323+
segment.to_string()
324+
};
325+
if !b.is_empty() && b[0].is_ascii_digit() {
326+
if let Ok(idx) = segment.parse::<usize>() {
327+
segs.push(PathSeg::Num { idx, key });
328+
continue;
329+
}
330+
}
331+
segs.push(PathSeg::Key(key));
332+
}
333+
segs
334+
}
335+
336+
#[rustler::nif]
337+
fn compile_paths<'a>(env: Env<'a>, paths: ListIterator<'a>, unique_keys: bool) -> Term<'a> {
338+
let mut out = Vec::new();
339+
for pt in paths {
340+
let p: &str = pt.decode().unwrap_or("");
341+
out.push(compile_one(p));
342+
}
343+
ResourceArc::new(CompiledPaths {
344+
paths: out,
345+
unique_keys,
346+
})
347+
.encode(env)
348+
}
349+
350+
/// Extract all compiled paths from an already-traversed `value` into a result
351+
/// list term, substituting nil for missing fields and depth-exceeded values.
352+
#[inline]
353+
fn extract_compiled<'a>(
354+
env: Env<'a>,
355+
value: &sonic_rs::Value,
356+
compiled: &CompiledPaths,
357+
) -> Term<'a> {
358+
let nil_raw = atoms::nil().as_c_arg();
359+
let n = compiled.paths.len();
360+
let mut stack: [ERL_NIF_TERM; GET_MANY_STACK] = [0; GET_MANY_STACK];
361+
let mut heap: Option<Vec<ERL_NIF_TERM>> = if n > GET_MANY_STACK {
362+
Some(Vec::with_capacity(n))
363+
} else {
364+
None
365+
};
366+
for (i, segs) in compiled.paths.iter().enumerate() {
367+
let r = match pointer_lookup_compiled(value, segs, compiled.unique_keys) {
368+
Some(v) => value_to_term(env, v, MAX_DEPTH)
369+
.map(|t| t.as_c_arg())
370+
.unwrap_or(nil_raw),
371+
None => nil_raw,
372+
};
373+
match &mut heap {
374+
Some(v) => v.push(r),
375+
None => stack[i] = r,
376+
}
377+
}
378+
let terms = match &heap {
379+
Some(v) => v.as_slice(),
380+
None => &stack[..n],
381+
};
382+
unsafe {
383+
Term::new(
384+
env,
385+
enif_make_list_from_array(env.as_c_arg(), terms.as_ptr(), n as u32),
386+
)
387+
}
388+
}
389+
390+
#[inline]
391+
fn do_parse_get_many_nil<'a>(env: Env<'a>, bytes: &[u8], compiled: &CompiledPaths) -> Term<'a> {
392+
match sonic_rs::from_slice::<sonic_rs::Value>(bytes) {
393+
Ok(value) => {
394+
let list = extract_compiled(env, &value, compiled);
395+
make_tuple2(env, atoms::ok().as_c_arg(), list.as_c_arg())
396+
}
397+
Err(e) => parse_error_term(env, format!("{}", e)),
398+
}
399+
}
400+
401+
#[rustler::nif]
402+
fn parse_get_many_nil<'a>(
403+
env: Env<'a>,
404+
json: Binary,
405+
compiled: ResourceArc<CompiledPaths>,
406+
) -> Term<'a> {
407+
let result = do_parse_get_many_nil(env, json.as_slice(), &compiled);
408+
schedule::consume_timeslice(env, timeslice_percent(json.len()));
409+
result
410+
}
411+
412+
#[rustler::nif(schedule = "DirtyCpu")]
413+
fn parse_get_many_nil_dirty<'a>(
414+
env: Env<'a>,
415+
json: Binary,
416+
compiled: ResourceArc<CompiledPaths>,
417+
) -> Term<'a> {
418+
do_parse_get_many_nil(env, json.as_slice(), &compiled)
419+
}
420+
421+
#[inline]
422+
fn pointer_lookup_compiled<'v>(
423+
value: &'v sonic_rs::Value,
424+
segs: &[PathSeg],
425+
unique_keys: bool,
426+
) -> Option<&'v sonic_rs::Value> {
427+
let mut current = value;
428+
for seg in segs {
429+
current = match seg {
430+
PathSeg::Key(k) => object_get(current, k, unique_keys)?,
431+
PathSeg::Num { idx, key } => {
432+
if current.is_array() {
433+
current.get(*idx)?
434+
} else {
435+
object_get(current, key, unique_keys)?
436+
}
437+
}
438+
};
439+
}
440+
Some(current)
441+
}
442+
443+
#[rustler::nif]
444+
fn get_many_nil_compiled<'a>(
445+
env: Env<'a>,
446+
doc: ResourceArc<ParsedDocument>,
447+
compiled: ResourceArc<CompiledPaths>,
448+
) -> Term<'a> {
449+
extract_compiled(env, &doc.value, &compiled)
450+
}
451+
300452
#[rustler::nif]
301453
fn get_many_nil<'a>(
302454
env: Env<'a>,

0 commit comments

Comments
 (0)