Repository navigation
Support GGUF external initializers and cross-session tensor sharing - #2864
Tianlei Wu (tianleiwu) wants to merge 12 commits into
Conversation
Run fixed eight-row Nemotron Bolt verification graphs with per-row Mamba state selection and the shared LiLiCorr DFlash2 drafter through the public Generator API. Validate greedy batch-one CUDA generation, state resets and acceptance boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Initialize the shared drafter feed by field name to preserve its optional prefix checkpoint and packed context contract. Validate only search fields exposed by the upstream stack; adaptive_chunking is not part of this base API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The C++17 span shim (src/span.h, used when USE_CXX17 is set) has no constructor from a C array, so passing `const int64_t shape[]` to OrtValue::CreateTensor fails to compile in the CI build. Use std::array, matching the pattern used elsewhere in the engine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run intermediate prefill target chunks asynchronously without host readback, ingest only the trailing drafter sliding-window suffix in a single proposal pass after prefill, and coalesce per-round target/drafter H2D and D2H transfers into aligned static buffers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reject decoder.sliding_window on NemotronBolt (while keeping the drafter's sliding window), apply model.decoder.run_options to NemotronBoltState before enforcing its synchronization override, and extend the synthetic DFlash2 anchor_row_index fixture to cover fixed-mode padding (1..8 context rows), window trimming, large prefill -> short proposal transitions, and reset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Require ORT API 27 so captured graphs are released, reject adapters that would select un-warmed graph identities, reset partial state when prefill fails, and make the sliding-window rejection test exercise the intended check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve Bolt's fixed proposals alongside upstream pinned-width graphs and host staging. Align reset coverage with main's cleared drafter caches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sor sharing Add gguf_initializers configuration support for decoder, dflash2, and mtp models, including a C++ GGUF v2/v3 header parser, cached mmap management, direct buffer injection via AddExternalInitializers, and optional cross-session device tensor sharing via AddInitializer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
I wonder onnx should support representing "externally supplied" weights in the models. A simple way to do it maybe allowing the path and offsets to be empty in the external tensor. Then allow runtimes to supply the initializers by name. |
|
Right now, an onnx model can already index into a gguf file and use it as the external data file. So the above would support tensor lookup by name (instead of the data offsets in current onnx external data format) |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues break Windows compilation, leave a Config ABI change unversioned, and allow CUDA and DML to share incompatible cached allocations, risking build failures and undefined behavior.
7 open issues
Bump the device interface version for the Config layout change · New Use the custom path API to open files on Windows · New Include the allocator in the GGUF cache key · New Validate draft GGUF paths before loading · New Build an unambiguous, deduplicated GGUF file search list · New Preserve explicitly empty scalar shapes · New Apply GGUF mappings to Parakeet and Nemotron Parse decoder sessions · New
What changed in this PR
Adds GGUF-backed initializer loading to GenAI model sessions, avoiding duplicated .onnx.data weights and enabling optional device tensor sharing between target and drafter models.
Changes:
- Adds GGUF mapping configuration for decoder, DFlash2, and MTP models.
- Implements cached GGUF parsing, direct-buffer registration, and shared device allocations.
- Documents packaging and configuration, with tests for parsing, path validation, and synthetic GGUF loading.
| File | Description |
|---|---|
test/cpp/engine/nemotron_bolt_tests.cpp |
Tests configuration, path checks, and synthetic GGUF validation. |
src/models/model.h |
Declares GGUF loading and retains external tensor views. |
src/models/model.cpp |
Implements GGUF parsing, buffer injection, and device sharing. |
src/dflash2_drafter.cpp |
Forwards GGUF configuration to the drafter model. |
src/config.h |
Defines GGUF mapping configuration structures. |
src/config.cpp |
Parses and validates mappings and forwards MTP configuration. |
docs/NemotronBolt.md |
Describes GGUF-backed Bolt packaging. |
docs/GGUFInitializers.md |
Documents mapping fields and runtime behavior. |
Review effort: Max
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use UTF-16-aware file opening and isolate shared buffers by allocator. Preserve scalar overrides, validate draft paths, and keep tensor-specific files out of the default search list. Register mappings on every supported decoder option set, reject pipelined decoders explicitly, and bump the Config/device ABI version. Add real ONNX/GGUF regression fixtures and retain main's opaque-buffer copy behavior after the merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks Justin Chu (@justinchuby) — agreed that ONNX external data can already index into a GGUF file when byte offsets are known. This PR adds lookup through the GGUF tensor directory by name and optional device-buffer sharing through the existing ORT initializer APIs; it does not change the ONNX schema. An explicit externally supplied-weight convention would complement this mechanism, but belongs in a separate ONNX/exporter discussion. Merged origin/main and addressed all seven review items in 78a268d. Local validation: CPU core and CUDA add-on builds passed; the full engine suite passed 1,644 tests with 118 hardware/opt-in skips, including all 14 new GGUF regressions. The fixtures verify real ONNX loading and tensor ranks/values without the original external data file. New Windows and Azure validation runs are now running on the updated, conflict-free PR. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical issues break supported C++17 builds and cause DML models with shared tensors to crash during teardown.
7 open issues
Reject DML sharing until allocator teardown is safe · New Replace C++20 bit_cast calls in C++17 builds · New Reject chained mapping config_file references · New Use lazy Windows file mapping for GGUF files · New Reject Moonshine until decoder mappings are registered · New Test zero-copy buffers and cached mapping lifetime · New Document zero-copy runtime prerequisites and fallbacks · New
7 resolved since last review
Include the allocator in the GGUF cache key Use the custom path API to open files on Windows Bump the device interface version for the Config layout change Apply GGUF mappings to Parakeet and Nemotron Parse decoder sessions Preserve explicitly empty scalar shapes Build an unambiguous, deduplicated GGUF file search list Validate draft GGUF paths before loading
Review effort: Max
- Map GGUF files read-only on Windows with CreateFileMappingW and MapViewOfFile (opened by UTF-16 path, handles closed by RAII) instead of retaining a file-sized heap buffer, and report empty files explicitly on all platforms. - Reject "share": true on DML, whose allocator is closed in ~Model while shared device tensors may still be alive. - Reject GGUF mappings for streaming_enc_dec_asr (Moonshine) models, which replace session_options_ and share it across all of their sessions. - Reject nested config_file references in external mapping files instead of silently ignoring them. - Replace std::bit_cast in the tests so they build as C++17, and add a 256-byte initializer fixture that exercises borrowed buffers across live and released models. - Document the ONNX Runtime prerequisites and fallback conditions for zero-copy initializer borrowing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run Integration Tests |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Baiju Meswani (baijumeswani)
left a comment
There was a problem hiding this comment.
Reviewed b66fd5b.
This PR lets an existing ONNX model read its weights from GGUF files by tensor name, instead of keeping another copy in .onnx.data. It can also share GPU weight allocations between the decoder and drafter. It is not a general GGUF model importer: a compatible ONNX graph and any required custom operators are still needed.
The approach has several good parts: read-only file mapping on both Windows and POSIX, model-owned references that keep mappings alive, byte-size and path checks, explicit scalar shapes, and clear rejection of unsupported model paths. The fixes for alternate decoder sessions and the device-interface version bump are also welcome.
I did not find a new heavyweight library dependency. The reader does not link GGML or llama.cpp. With matching Linux Release compiler settings, the two main changed object files grew by about 62 KiB in total sections; this is not an exact final shared-library size measurement. The code is included even when GGUF is unused. External custom-operator libraries have a separate package footprint that I did not measure.
Before approval, I would like the retained host-copy issue addressed, unused unsupported tensors kept from blocking supported mappings, and direct tests for GPU allocation reuse and actual ORT buffer borrowing. The inline comments explain these requests. The schema simplification comment is a suggestion, not an approval requirement.
The zero-copy benefit also depends on an ORT build containing microsoft/onnxruntime#33877. The documentation now explains that correctly. Some build and benchmark dependency paths still select older ORT versions, so benchmark results should record the actual runtime and should not assume the session option alone prevents copying. Name-based lookup and device sharing are the added value here; fixed packages can already reference weight-file byte ranges through ONNX external-data offsets.
Validation: an isolated CPU build passed the focused GGUF/Nemotron tests (27 passed, one GPU/package-dependent test skipped), and the GGUF tests passed a C++17 syntax check. A separate probe mapping only an F32 tensor failed when the same file contained an unused FP8 tensor, with Unsupported GGUF tensor type: 43. I did not independently verify the reported GPU memory or throughput results.
| shared_entry = std::make_shared<SharedInitializerEntry>(); | ||
| shared_entry->device_data = p_device_->Allocate<uint8_t>(matched_info->n_bytes); | ||
| shared_entry->device_data.CopyFromCpu( | ||
| std::span<const uint8_t>(static_cast<const uint8_t*>(tensor_ptr), matched_info->n_bytes)); |
There was a problem hiding this comment.
This upload keeps a full-sized host copy on AMDGPU and WebGPU. Those buffers inherit DeviceBuffer::CopyFromCpu, which allocates a CPU mirror, copies into it, and uploads it. Their destructors are the only place that mirror is freed. The shared cache therefore keeps that extra host allocation alive for as long as the shared weights are used. For example, sharing 10 GiB of weights also retains about 10 GiB of private host memory, in addition to the file mapping and device allocation.
Please upload directly from the mapped CPU view without retaining a staging mirror, using the existing backend copy facilities with the required synchronization. Alternatively, limit sharing to the backends that have been validated. A test that counts host staging allocations would help keep this from coming back.
There was a problem hiding this comment.
Good catch, and confirmed. AMDGPU GpuMemory and WebGPU WebGPUMemory inherited the base DeviceBuffer::CopyFromCpu, which allocates p_cpu_, copies into it, and keeps it until the buffer is destroyed. Fixed in 0a25032: both now override CopyFromCpu to upload straight from the caller's buffer. They use the same CopyTensors call that their CopyCpuToDevice already makes, with a null stream, so the copy completes before the call returns. No mirror is allocated or retained.
The other backends were already fine. CUDA uploads directly, QNN/RyzenAI/OpenVINO buffers are host memory (p_cpu_ == p_device_), and DML sharing stays rejected. The existing shared_initializers path makes the same call, so it no longer keeps a host copy either.
Tests: models now record their shared GGUF device allocations (gguf_shared_device_data_). ExpectUploadedWithoutHostCopy asserts that the shared buffer keeps no separate host copy and contains the GGUF bytes. It runs in the new CUDA sharing test and in WebGpuSharedUploadsKeepNoHostCopy, which runs wherever a WebGPU EP is available. Switching the upload back to CpuSpan() + CopyCpuToDevice() makes the CUDA check fail. I couldn't run the AMDGPU override here because no AMDGPU EP is available; its change is identical to the WebGPU one.
| info.rel_offset = read_u64(); | ||
|
|
||
| size_t blck_size = 1, type_size = 1; | ||
| GetGgmlTypeGeometry(info.ggml_type, blck_size, type_size, info.default_dtype); |
There was a problem hiding this comment.
This checks every tensor's type before we know which tensors the mapping actually uses. A valid but unsupported type in an unused tensor therefore rejects the whole file. I reproduced this with a mapping that selects only an F32 tensor: adding an unused F8_E4M3 tensor makes loading fail with Unsupported GGUF tensor type: 43.
Please separate reading the tensor directory from validating the selected tensors. Unsupported selected types should still fail, but an unused tensor should not prevent a supported mapping from loading. Add a regression for this mixed-type case.
There was a problem hiding this comment.
Agreed. Fixed in 0a25032. Header parsing now only reads the tensor directory: name, GGML dims, type, and offset. It still rejects invalid ranks and duplicate names, since the directory can't be read safely or looked up without those. Type support, block geometry, byte-size overflow, and file bounds are now checked in GgufFileEntry::Resolve, which runs only for tensors that a mapping selects.
A selected unsupported tensor still fails, and the error now names the tensor and file: Unsupported GGUF tensor type 43 for tensor 'unsupported' in <path>. The new OnlySelectedTensorsAreValidated test covers your case. Its file contains an unused type-43 tensor and an unused tensor whose data runs past EOF. The supported F32 mapping loads and runs, and selecting either of the other tensors fails with its specific error.
| } | ||
| } | ||
|
|
||
| TEST_F(GgufInitializersTest, DmlRejectsSharedTensors) { |
There was a problem hiding this comment.
The non-CPU sharing test here only checks that DML is rejected. The successful cases use CPU, where share is ignored, so the new GPU allocation-sharing path has no positive regression test.
Please add a GPU test that loads two sessions using the same shared tensor, verifies that only one device allocation is used, releases one model, and runs the survivor again. Also check that different device identities or tensor layouts do not reuse an incompatible allocation. This is needed to protect the main GPU-memory benefit of the change.
There was a problem hiding this comment.
Added CudaSharedTensorsUseOneDeviceAllocation in 0a25032. It loads two CUDA models with the same share: true mapping, like a target and its drafter, and checks:
- Both models bind the same device allocation (
SameBufferAs), and that buffer keeps no host copy. - Both sessions produce correct outputs.
- After one model and its session are released, the surviving session still runs correctly.
- A second registration through a counting device makes no allocations.
- Mapping the same bytes with shape
[8, 8], withdata_typeINT32, or through a device that reports another device id makes a new allocation each time.
GenAI binds one CUDA device per process, so the device-identity case uses a forwarding device that reports a different id; no session runs on that allocation. The test passes on an H200. I also checked that it catches regressions: disabling the cache lookup, or removing the layout and device id from the cache key, makes the corresponding assertions fail.
| ASSERT_EQ(second->gguf_external_values_.size(), 1u); | ||
| const void* data = first->gguf_external_values_[0]->GetTensorRawData(); | ||
| EXPECT_EQ(reinterpret_cast<uintptr_t>(data) % sizeof(float), 0u); | ||
| EXPECT_EQ(second->gguf_external_values_[0]->GetTensorRawData(), data); |
There was a problem hiding this comment.
This proves that the loader reuses the same mapped address, but not that ORT borrows that address. The session-output checks also pass if ORT makes a private copy. I ran this test successfully with the older ORT SDK available locally, which exercises the copying fallback.
Please keep this lifetime test and add a separate check, using a compatible ORT build, that observes the initializer address inside the session or otherwise detects an extra initializer allocation. That check should fail if the direct-buffer option is removed. It would protect the zero-copy claim rather than only the loader's file cache.
There was a problem hiding this comment.
Kept the lifetime test, and added SessionsReadMappedTensorsWithoutCopying in 0a25032. After creating the session, the test rewrites the tensor's bytes in large.gguf through a writable shared mapping. It then expects the next Run to return the new values, which only happens if the session reads the mapped buffer instead of a private copy.
The test first runs a probe that does the same check directly against ORT, with AddExternalInitializers and the option set explicitly. If the runtime still copies, the test skips instead of failing.
Results:
- It passes with ORT builds that include Support direct external initializer buffers in AddExternalInitializers onnxruntime#33877.
- It fails, returning the original values, when the
session.use_external_initializer_file_buffers_directlyline is removed fromAddGgufInitializers. The lifetime test still passes in that case. - It skips with ORT 1.31.
| void OnValue(std::string_view name, JSON::Value value) override { | ||
| if (name == "onnx_name" || name == "name") { | ||
| v_.onnx_name = JSON::Get<std::string_view>(value); | ||
| } else if (name == "gguf_name" || name == "gguf_tensor" || name == "tensor") { |
There was a problem hiding this comment.
Could we reduce the number of spellings before this becomes public configuration? We currently accept onnx_name/name, gguf_name/gguf_tensor/tensor, file/data_file, and data_type/dtype, as well as object and array forms. Each spelling becomes something we need to preserve and test.
Unless existing packages need these aliases, I suggest choosing one documented spelling for each field. This is a design suggestion, not an approval requirement.
There was a problem hiding this comment.
Agreed. Nothing in this repo or in the documented examples uses the aliases, so in 0a25032 I narrowed the schema before it becomes public. Each field now has one spelling:
- Top level:
config_file,files,share, andtensorsin object form. - Each
tensorsvalue is either a GGUF tensor name or an object withgguf_name,file,data_type,shape, andshare.
I removed onnx_name/name, gguf_tensor/tensor, data_file, dtype, the single-string file, and the array form of tensors. They now fail with Unknown value "...", which MappingFieldsHaveOneSpelling pins down. I kept the "gguf_initializers": "mapping.json" shorthand because it's the documented way to reference an external mapping file. Docs and tests are updated.
Shared GGUF tensors are cached for as long as any model uses them, so a host staging copy left in the device buffer doubled their host memory on AMDGPU and WebGPU. Those buffers now upload straight from the source. Validate GGUF tensor types, geometry, and bounds only for tensors that a mapping selects, so an unused tensor of an unsupported type no longer prevents loading a supported mapping. Mapping fields now accept a single spelling, and tensors accept only the object form. Add tests for CUDA shared-allocation reuse, release, and cache-key isolation; for host-copy-free uploads on CUDA and WebGPU; and for ONNX Runtime reading mapped GGUF data in place instead of copying it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks Baiju Meswani (@baijumeswani). I addressed this review in 0a25032:
Validation, on a Linux CUDA 13.0 build running on an H200:
|



Description
Adds native support for loading ONNX model initializers directly from GGUF v2/v3 weight files (
.gguf) via a newgguf_initializersconfiguration ingenai_config.json(supported onmodel.decoder,model.dflash2, andmodel.mtp), eliminating the need to duplicate weights into.onnx.dataexternal data files.When paired with ONNX Runtime's direct external initializer buffer support (microsoft/onnxruntime#33877) and GGUF custom operators (
gguf_onnx_ops), the 88-layerNVIDIA-Nemotron-3.5-Bolt-74B-A9Btarget model (model.onnx= 210 KB) loads all 843 weight tensors (20.5 GiB) directly fromNVIDIA-Nemotron-3.5-Bolt-74B-A9B-24GB.ggufwith 0 bytes ofmodel.onnx.data, while sharingtoken_embd.weightandoutput.weightin GPU memory with the LiLiCorrdflash2drafter ("share": true), cutting session load time by 2.34x (11.43 s -> 4.89 s) and reducing peak GPU memory by 22.3 GiB (49.4 GiB -> 27.0 GiB).Stacked on #2761 (
tlwu/20261019/nemotron-bolt-runtime).Summary of Changes
Configuration & GGUF Initializer Loading
src/config.hGgufTensorMapping,GgufInitializers, andLoadGgufInitializersConfigdeclarations fordecoder,dflash2, andmtp.src/config.cppgguf_initializers(as an external JSON file path or inline object, supporting map and arraytensorssyntax) and validate all GGUF paths inValidateModelPathsandLoadGgufInitializersConfig.src/dflash2_drafter.cppdflash2.gguf_initializersinto the drafter'sConfiginCreateDflash2Config.src/models/model.hAddGgufInitializers()andgguf_external_values_storage onModel.src/models/model.cppGgufFileEntry(GGUF v2/v3 binary header parser with bounds/overflow checks and cachedmmap), automatic ONNX shape/dtype inference for GGUF block-quantized and unquantized types, zero-copyAddExternalInitializersinjection withsession.use_external_initializer_file_buffers_directly = "1", and cross-session GPU tensor sharing viaAddInitializerwhen"share": true.Documentation & Tests
docs/GGUFInitializers.mdgguf_initializersschema, supported GGUF types, zero-copymmaploading, and cross-session GPU sharing.docs/NemotronBolt.mdgguf_initializers.jsonanddflash2_gguf_initializers.json.test/cpp/engine/nemotron_bolt_tests.cppNemotronBolt.GgufInitializersConfigParsingAndPathValidationtesting config parsing (decoder,dflash2,mtp), path traversal rejection, and synthetic GGUF v3 binary header parsing/validation.Testing
./build/cuda/engine_unit_tests --ep_dir $PACKAGE/runtime/lib --gtest_filter='NemotronBolt*'(9/9 CPU + GPU tests pass)nemotron_bolt_74b_genai_gguf: 120 / 200 = 60.0% (matchesllama.cppGGUF andrc1120 / 200 = 60.0%)fixed_budget_eos_disabled), 4.89 s model load time, 27,043 MiB peak GPU memoryChecklist