Paul White

Minimal Logging with INSERT…SELECT and Fast Load Context

Download the SentryOne Plan Explorer Extension for Azure Data Studio
SentryOne Newsletters

The SQLPerformance.com bi-weekly newsletter keeps you up to speed on the most recent blog posts and forum discussions in the SQL Server community.

eNews is a bi-monthly newsletter with fun information about SentryOne, tips to help improve your productivity, and much more.

Subscribe

Featured Author

Itzik is a T-SQL trainer, a co-founder of SolidQ, and blogs about T-SQL fundamentals and query tuning.

Itzik’s Posts

This post provides new information about the preconditions for minimally logged bulk load when using INSERT...SELECT into indexed tables.

The internal facility that enables these cases is called FastLoadContext. It can be activated from SQL Server 2008 to 2014 inclusive using documented trace flag 610. From SQL Server 2016 onward, FastLoadContext is enabled by default; the trace flag is not required.

Without FastLoadContext, the only index inserts that can be minimally logged are those into an empty clustered index without secondary indexes, as covered in part two of this series. The minimal logging conditions for unindexed heap tables were covered in part one.

For more background, see the Data Performance Loading Guide and the Tiger Team notes on the behaviour changes for SQL Server 2016.

Fast Load Context

As a quick reminder, the RowsetBulk facility (covered in parts 1 and 2) enables minimally-logged bulk load for:

  • Empty and non-empty heap tables with:
    • Table locking; and
    • No secondary indexes.
  • Empty clustered tables, with:
    • Table locking; and
    • No secondary indexes; and
    • DMLRequestSort=true on the Clustered Index Insert operator.

The FastLoadContext code path adds support for minimally-logged and concurrent bulk load on:

  • Empty and non-empty clustered b-tree indexes.
  • Empty and non-empty nonclustered b-tree indexes maintained by a dedicated Index Insert plan operator.

The FastLoadContext also requires DMLRequestSort=true on the corresponding plan operator in all cases.

You may have noticed an overlap between RowsetBulk and FastLoadContext for empty clustered tables with no secondary indexes. A TABLOCK hint is not required with FastLoadContext, but it is not required to be absent either. As a consequence, a suitable insert with TABLOCK may still qualify for minimal logging via FastLoadContext if it fails the detailed RowsetBulk tests.

FastLoadContext can be disabled on SQL Server 2016 using documented trace flag 692. The Debug channel Extended Event fastloadcontext_enabled can be used to monitor FastLoadContext usage per index partition (rowset). This event does not fire for RowsetBulk loads.

Mixed logging

A single INSERT...SELECT statement using FastLoadContext may fully log some rows while minimally logging others.

Rows are inserted one at a time by the Index Insert operator and fully logged in the following cases:

  • All rows added to the first index page, if the index was empty at the start of the operation.
  • Rows added to existing index pages.
  • Rows moved between pages by a page split.

Otherwise, rows from the ordered insert stream are added to a brand new page using an optimized, and minimally-logged code path. Once as many rows as possible are written to the new page, it is directly linked into the existing target index structure.

The newly added page will not necessarily be full (though obviously that is the ideal case) because SQL Server has to be careful not to add rows to the new page that logically belong on an existing index page. The new page will be 'stitched into' the index as a unit, so we cannot have any rows on the new page that belong elsewhere. This is primarily an issue when adding rows within the existing key range of index, rather than before the start or after the end of the existing index key range.

It is still possible to add new pages within the existing index key range, but the new rows must sort higher than the highest key on the preceding existing index page and sort lower than the lowest key on the following existing index page. For the best chance of achieving minimal logging in these circumstances, ensure the inserted rows do not overlap with existing rows as far as possible.

DMLRequestSort Conditions

Remember that FastLoadContext can only be activated if DMLRequestSort is set to true for the corresponding Index Insert operator in the execution plan.

There are two main code paths that can set DMLRequestSort to true for index inserts. Either path returning true is sufficient.

1. FOptimizeInsert

The sqllang!CUpdUtil::FOptimizeInsert code requires:

  • More than 250 rows estimated to be inserted; and
  • More than 2 pages estimated insert data size; and
  • The target index must have fewer than 3 leaf pages.

These conditions are the same as RowsetBulk on an empty clustered index, with an additional requirement for no more than two index leaf-level pages. Note carefully that this refers to the size of the existing index before the insert, not the estimated size of the data to be added.

The script below is a modification of the demo used in earlier parts in this series. It shows minimal logging when fewer than three index pages are populated before the test INSERT...SELECT runs. The test table schema is such that 130 rows can fit on a single 8KB page when row versioning is off for the database. The multiplier in the first TOP clause can be changed to determine the number of existing index pages before the test INSERT...SELECT is executed:

IF OBJECT_ID(N'dbo.Test', N'U') IS NOT NULL
BEGIN
    DROP TABLE dbo.Test;
END;
GO
CREATE TABLE dbo.Test 
(
    id integer NOT NULL IDENTITY
        CONSTRAINT [PK dbo.Test (id)]
        PRIMARY KEY,
    c1 integer NOT NULL,
    padding char(45) NOT NULL
        DEFAULT ''
);
GO
-- 130 rows per page for this table 
-- structure with row versioning off
INSERT dbo.Test
    (c1)
SELECT TOP (3 * 130)    -- Change the 3 here
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV;
GO
-- Show physical index statistics
-- to confirm the number of pages
SELECT
    DDIPS.index_type_desc,
    DDIPS.alloc_unit_type_desc,
    DDIPS.page_count,
    DDIPS.record_count,
    DDIPS.avg_record_size_in_bytes
FROM sys.dm_db_index_physical_stats
(
    DB_ID(), 
    OBJECT_ID(N'dbo.Test', N'U'), 
    1,      -- Index ID
    NULL,   -- Partition ID
    'DETAILED'
) AS DDIPS
WHERE
    DDIPS.index_level = 0;  -- leaf level only
GO
-- Clear the plan cache
DBCC FREEPROCCACHE;
GO
-- Clear the log
CHECKPOINT;
GO
-- Main test
INSERT dbo.Test
    (c1)
SELECT TOP (269)
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV;
GO
-- Show log entries
SELECT
    FD.Operation,
    FD.Context,
    FD.[Log Record Length],
    FD.[Log Reserve],
    FD.AllocUnitName,
    FD.[Transaction Name],
    FD.[Lock Information],
    FD.[Description]
FROM sys.fn_dblog(NULL, NULL) AS FD;
GO
-- Count the number of  fully-logged rows
SELECT 
    [Fully Logged Rows] = COUNT_BIG(*) 
FROM sys.fn_dblog(NULL, NULL) AS FD
WHERE 
    FD.Operation = N'LOP_INSERT_ROWS'
    AND FD.Context = N'LCX_CLUSTERED'
    AND FD.AllocUnitName = N'dbo.Test.PK dbo.Test (id)';
GO

When the clustered index is preloaded with 3 pages, the test insert is fully logged (transaction log detail records omitted for brevity):

Full logging with 3 pre-existing index pages

When the table is preloaded with only 1 or 2 pages, the test insert is minimally logged:

Minimal logging with 1 existing page

Minimal logging with 2 existing pages

When the table is not preloaded with any pages, the test is equivalent to running the empty clustered table demo from part two, but without the TABLOCK hint:

Test with empty table

The first 130 rows are fully logged. This is because the index was empty before we started, and 130 rows fit on the first page. Remember, the first page is always fully logged when FastLoadContext is used and the index was empty beforehand. The remaining 139 rows are inserted with minimal logging.

If a TABLOCK hint is added to the insert, all pages are minimally logged (including the first one) since the empty clustered index load now qualifies for the RowsetBulk mechanism (at the cost of taking an Sch-M lock).

2. FDemandRowsSortedForPerformance

If the FOptimizeInsert tests fail, DMLRequestSort may still be set to true by a second set of tests in the sqllang!CUpdUtil::FDemandRowsSortedForPerformance code. These conditions are a little more complex, so it will be useful to define some parameters:

  • P – number of existing leaf-level pages in the target index.
  • Iestimated number of rows to insert.
  • R = P / I (target pages per inserted row).
  • T – number of target partitions (1 for unpartitioned).

The logic to determine the value of DMLRequestSort is then:

  • If P <= 16 return false, otherwise:
    • If R < 8:
      • If P > 524 return true, otherwise false.
    • If R >= 8 :
      • If T > 1 and I > 250 return true, otherwise false.

The above tests are evaluated by the query processor during plan compilation. There is a final condition evaluated by storage engine code (IndexDataSetSession::WakeUpInternal) at execution time:

  • DMLRequestSort is currently true; and
  • I >= 100.

We will break all this logic down into manageable pieces next.

More than 16 existing target pages

The first test P <= 16 means that indexes with fewer than 17 existing leaf pages will not qualify for FastLoadContext via this code path. To be absolutely clear on this point, P is the number of leaf-level pages in the target index before the INSERT...SELECT is executed.

To demonstrate this part of the logic, we will preload the test clustered table with 16 pages of data. This has two important effects (remember both code paths must return false to end up with a false value for DMLRequestSort):

  1. It ensures that the previous FOptimizeInsert test fails, because the third condition is not met (P < 3).
  2. The P <= 16 condition in FDemandRowsSortedForPerformance will also not be met.

We therefore expect FastLoadContext not to be enabled. The modified demo script is:

IF OBJECT_ID(N'dbo.Test', N'U') IS NOT NULL
BEGIN
    DROP TABLE dbo.Test;
END;
GO
CREATE TABLE dbo.Test 
(
    id integer NOT NULL IDENTITY
        CONSTRAINT [PK dbo.Test (id)]
        PRIMARY KEY,
    c1 integer NOT NULL,
    padding char(45) NOT NULL
        DEFAULT ''
);
GO
-- 130 rows per page for this table 
-- structure with row versioning off
INSERT dbo.Test
    (c1)
SELECT TOP (16 * 130) -- 16 pages
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV;
GO
-- Show physical index statistics
-- to confirm the number of pages
SELECT
    DDIPS.index_type_desc,
    DDIPS.alloc_unit_type_desc,
    DDIPS.page_count,
    DDIPS.record_count,
    DDIPS.avg_record_size_in_bytes
FROM sys.dm_db_index_physical_stats
(
    DB_ID(), 
    OBJECT_ID(N'dbo.Test', N'U'), 
    1,      -- Index ID
    NULL,   -- Partition ID
    'DETAILED'
) AS DDIPS
WHERE
    DDIPS.index_level = 0;  -- leaf level only
GO
-- Clear the plan cache
DBCC FREEPROCCACHE;
GO
-- Clear the log
CHECKPOINT;
GO
-- Main test
INSERT dbo.Test
    (c1)
SELECT TOP (269)
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV1
CROSS JOIN master.dbo.spt_values AS SV2;
GO
-- Show log entries
SELECT
    FD.Operation,
    FD.Context,
    FD.[Log Record Length],
    FD.[Log Reserve],
    FD.AllocUnitName,
    FD.[Transaction Name],
    FD.[Lock Information],
    FD.[Description]
FROM sys.fn_dblog(NULL, NULL) AS FD;
GO
-- Count the number of  fully-logged rows
SELECT 
    [Fully Logged Rows] = COUNT_BIG(*) 
FROM sys.fn_dblog(NULL, NULL) AS FD
WHERE 
    FD.Operation = N'LOP_INSERT_ROWS'
    AND FD.Context = N'LCX_CLUSTERED'
    AND FD.AllocUnitName = N'dbo.Test.PK dbo.Test (id)';

All 269 rows are fully logged as predicted:

Test with 16 pre-existing pages

Note that no matter how high we set the number of new rows to insert, the script above will never produce minimal logging because of the P <= 16 test (and P < 3 test in FOptimizeInsert).

If you choose to run the demo yourself with a larger number of rows, comment out the section that shows individual transaction log records, otherwise you will be waiting a very long time, and SSMS may crash. (To be fair, it might do that anyway, but why add to the risk.)

Pages per inserted row ratio

If there are 17 or more leaf pages in the existing index, the previous P <= 16 test will not fail. The next section of logic deals with the ratio of existing pages to newly inserted rows. This must also pass to achieve minimal logging. As a reminder, the relevant conditions are:

  • Ratio R = P / I.
  • If R < 8:
    • If P > 524 return true, otherwise false.

We must also remember the final storage engine test for at least 100 rows:

  • I >= 100.

Reorganizing those conditions a bit, all of the following must be true:

  1. P > 524 (existing index pages)
  2. I >= 100 (estimated inserted rows)
  3. P / I < 8 (ratio R)

There are multiple ways to meet those three conditions simultaneously. Let's choose the minimal possible values for P (525) and I (100) giving an R value of (525 / 100) = 5.25. This satisfies the (R < 8 test), so we expect this combination to result in minimal logging:

IF OBJECT_ID(N'dbo.Test', N'U') IS NOT NULL
BEGIN
    DROP TABLE dbo.Test;
END;
GO
CREATE TABLE dbo.Test 
(
    id integer NOT NULL IDENTITY
        CONSTRAINT [PK dbo.Test (id)]
        PRIMARY KEY,
    c1 integer NOT NULL,
    padding char(45) NOT NULL
        DEFAULT ''
);
GO
-- 130 rows per page for this table 
-- structure with row versioning off
INSERT dbo.Test
    (c1)
SELECT TOP (525 * 130) -- 525 pages
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV1
CROSS JOIN master.dbo.spt_values AS SV2;
GO
-- Show physical index statistics
-- to confirm the number of pages
SELECT
    DDIPS.index_type_desc,
    DDIPS.alloc_unit_type_desc,
    DDIPS.page_count,
    DDIPS.record_count,
    DDIPS.avg_record_size_in_bytes
FROM sys.dm_db_index_physical_stats
(
    DB_ID(), 
    OBJECT_ID(N'dbo.Test', N'U'), 
    1,      -- Index ID
    NULL,   -- Partition ID
    'DETAILED'
) AS DDIPS
WHERE
    DDIPS.index_level = 0;  -- leaf level only
GO
-- Clear the plan cache
DBCC FREEPROCCACHE;
GO
-- Clear the log
CHECKPOINT;
GO
-- Main test
INSERT dbo.Test
    (c1)
SELECT TOP (100)
    CHECKSUM(NEWID())
FROM master.dbo.spt_values AS SV1
CROSS JOIN master.dbo.spt_values AS SV2;
GO
-- Show log entries
SELECT
    FD.Operation,
    FD.Context,
    FD.[Log Record Length],
    FD.[Log Reserve],
    FD.AllocUnitName,
    FD.[Transaction Name],
    FD.[Lock Information],
    FD.[Description]
FROM sys.fn_dblog(NULL, NULL) AS FD;
GO
-- Count the number of  fully-logged rows
SELECT 
    [Fully Logged Rows] = COUNT_BIG(*) 
FROM sys.fn_dblog(NULL, NULL) AS FD
WHERE 
    FD.Operation = N'LOP_INSERT_ROWS'
    AND FD.Context = N'LCX_CLUSTERED'
    AND FD.AllocUnitName = N'dbo.Test.PK dbo.Test (id)';

The 100-row INSERT...SELECT is indeed minimally logged:

Minimal logging with 525 pages and 100 rows

Reducing the estimated inserted rows to 99 (breaking I >= 100), and/or reducing the number of existing index pages to 524 (breaking P > 524) results in full logging. We could also make changes such that R is no longer less than 8 to produce full logging. For example, setting P = 1000 and I = 125 gives R = 8, with the following results:

Full logging with 1000 pages and 125 inserted rows

The 125 inserted rows were fully logged as expected. (This is not due to first page full logging, since the index was not empty beforehand.)

Page ratio for partitioned indexes

If all the preceding tests fail, the one remaining test requires R >= 8 and can only be satisfied when the number of partitions (T) is greater than 1 and there are more than 250 estimated inserted rows (I). Recall:

  • If R >= 8 :
    • If T > 1 and I > 250 return true, otherwise false.

One subtlety: For partitioned indexes, the rule that says all first-page rows are fully logged (for an initially empty index) applies per partition. For an object with 15,000 partitions, that means 15,000 fully logged 'first' pages.

Summary and Final Thoughts

The formulas and order of evaluation described in the body are based on code inspection using a debugger. They were presented in a form that closely represents the timing and order used in the real code.

It is possible to reorder and simplify those conditions a bit, to produce a more concise summary of the practical requirements for minimal logging when inserting into a b-tree using INSERT...SELECT. The refined expressions below use the following three parameters:

  • P = number of existing index leaf-level pages.
  • I = estimated number of rows to insert.
  • S = estimated insert data size in 8KB pages.

Rowset bulk load

  • Uses sqlmin!RowsetBulk.
  • Requires an empty clustered index target with TABLOCK (or equivalent).
  • Requires DMLRequestSort = true on the Clustered Index Insert operator.
  • DMLRequestSort is set true if I > 250 and S > 2.
  • All inserted rows are minimally logged.
  • An Sch-M lock prevents concurrent table access.

Fast load context

  • Uses sqlmin!FastLoadContext.
  • Enables minimally-logged inserts to b-tree indexes:
    • Clustered or nonclustered.
    • With or without a table lock.
    • Target index empty or not.
  • Requires DMLRequestSort = true on the associated Index Insert plan operator.
  • Only rows written to brand new pages are bulk loaded and minimally logged.
  • The first page of a previously empty index partition is always fully logged.
  • Absolute minimum of I >= 100.
  • Requires trace flag 610 before SQL Server 2016.
  • Available by default from SQL Server 2016 (trace flag 692 disables).

DMLRequestSort is set true for:

  • Any index (partitioned or not) if:
    • I > 250 and P < 3 and S > 2; or
    • I >= 100 and P > 524 and P < I * 8

For partitioned indexes only (with > 1 partition), DMLRequestSort is also set true if:

  • I > 250 and P > 16 and P >= I * 8

There are a few interesting cases arising from those FastLoadContext conditions:

  • All inserts to a non-partitioned index with between 3 and 524 (inclusive) existing leaf pages will be fully logged regardless of the number and total size of the rows added. This will most noticeably affect large inserts to small (but not empty) tables.
  • All inserts to a partitioned index with between 3 and 16 existing pages will be fully logged.
  • Large inserts to large non-partitioned indexes may not be minimally logged due to the inequality P < I * 8. When P is large, a correspondingly large estimated number of inserted rows (I) is required. For example, an index with 8 million pages cannot support minimal logging when inserting 1 million rows or fewer.

Nonclustered indexes

The same considerations and calculations applied to clustered indexes in the demos apply to nonclustered b-tree indexes as well, as long as the index is maintained by a dedicated plan operator (a wide, or per-index plan). Nonclustered indexes maintained by a base table operator (e.g. Clustered Index Insert) are not eligible for FastLoadContext.

Note that the formula parameters need to be evaluated afresh for each nonclustered index operator — calculated row size, number of existing index pages, and cardinality estimate.

General remarks

Watch out for low cardinality estimates at the Index Insert operator, as these will affect the I and S parameters. If a threshold is not reached due to a cardinality estimation error, the insert will be fully logged.

Remember that DMLRequestSort is cached with the plan — it is not evaluated on each execution of a reused plan. This can introduce a form of the well-known Parameter Sensitivity Problem (also known as "parameter sniffing").

The value of P (index leaf pages) is not refreshed at the start of every statement. The current implementation caches the value for the whole batch. This can have unexpected side-effects. For example, a TRUNCATE TABLE in the same batch as an INSERT...SELECT will not reset P to zero for the calculations described in this article — they will continue to use the pre-truncate value, and a recompilation will not help. A workaround is to submit large changes in separate batches.

Trace Flags

It is possible to force FDemandRowsSortedForPerformance to return true by setting undocumented and unsupported trace flag 2332, as I wrote in Optimizing T-SQL queries that change data. When TF 2332 is active, the number of estimated rows to insert still needs to be at least 100. TF 2332 affects the minimal logging decision for FastLoadContext only (it is effective for partitioned heaps as far as DMLRequestSort is concerned, but has no effect on the heap itself, since FastLoadContext only applies to indexes).

A wide/per-index plan shape for nonclustered index maintenance can be forced for rowstore tables using trace flag 8790 (not officially documented, but mentioned in a Knowledge Base article as well as in my article as linked for TF2332 just above).

All by Sunil Agarwal from the SQL Server team: